diff --git a/CHANGELOG.md b/CHANGELOG.md index e93f3b8342104..99124fabcc3e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,87 @@ # 6.4.0 (unreleased) -### Breaking changes +# 6.4.0-beta1 (2019-09-17) + +### Features / Enhancements +* **API**: Readonly datasources should not be created via the API. [#19006](https://github.com/grafana/grafana/pull/19006), [@papagian](https://github.com/papagian) +* **Alerting**: Include configured AlertRuleTags in Webhooks notifier. [#18233](https://github.com/grafana/grafana/pull/18233), [@dominic-miglar](https://github.com/dominic-miglar) +* **Annotations**: Add annotations support to Loki. [#18949](https://github.com/grafana/grafana/pull/18949), [@aocenas](https://github.com/aocenas) +* **Annotations**: Use a single row to represent a region. [#17673](https://github.com/grafana/grafana/pull/17673), [@ryantxu](https://github.com/ryantxu) +* **Auth**: Allow inviting existing users when login form is disabled. [#19048](https://github.com/grafana/grafana/pull/19048), [@548017](https://github.com/548017) +* **Azure Monitor**: Add support for cross resource queries. [#19115](https://github.com/grafana/grafana/pull/19115), [@sunker](https://github.com/sunker) +* **CLI**: Allow installing custom binary plugins. [#17551](https://github.com/grafana/grafana/pull/17551), [@aocenas](https://github.com/aocenas) +* **Dashboard**: Adds Logs Panel (alpha) as visualization option for Dashboards. [#18641](https://github.com/grafana/grafana/pull/18641), [@hugohaggmark](https://github.com/hugohaggmark) +* **Dashboard**: Reuse query results between panels . [#16660](https://github.com/grafana/grafana/pull/16660), [@ryantxu](https://github.com/ryantxu) +* **Dashboard**: Set time to to 23:59:59 when setting To time using calendar. [#18595](https://github.com/grafana/grafana/pull/18595), [@simPod](https://github.com/simPod) +* **DataLinks**: Add DataLinks support to Gauge, BarGauge and SingleStat2 panel. [#18605](https://github.com/grafana/grafana/pull/18605), [@ryantxu](https://github.com/ryantxu) +* **DataLinks**: Enable access to labels & field names. [#18918](https://github.com/grafana/grafana/pull/18918), [@torkelo](https://github.com/torkelo) +* **DataLinks**: Enable multiple data links per panel. [#18434](https://github.com/grafana/grafana/pull/18434), [@dprokop](https://github.com/dprokop) +* **Docker**: switch docker image to alpine base with phantomjs support. [#18468](https://github.com/grafana/grafana/pull/18468), [@DanCech](https://github.com/DanCech) +* **Elasticsearch**: allow templating queries to order by doc_count. [#18870](https://github.com/grafana/grafana/pull/18870), [@hackery](https://github.com/hackery) +* **Explore**: Add throttling when doing live queries. [#19085](https://github.com/grafana/grafana/pull/19085), [@aocenas](https://github.com/aocenas) +* **Explore**: Adds ability to go back to dashboard, optionally with query changes. [#17982](https://github.com/grafana/grafana/pull/17982), [@kaydelaney](https://github.com/kaydelaney) +* **Explore**: Reduce default time range to last hour. [#18212](https://github.com/grafana/grafana/pull/18212), [@davkal](https://github.com/davkal) +* **Gauge/BarGauge**: Support decimals for min/max. [#18368](https://github.com/grafana/grafana/pull/18368), [@ryantxu](https://github.com/ryantxu) +* **Graph**: New series override transform constant that renders a single point as a line across the whole graph. [#19102](https://github.com/grafana/grafana/pull/19102), [@davkal](https://github.com/davkal) +* **Image rendering**: Add deprecation warning when PhantomJS is used for rendering images. [#18933](https://github.com/grafana/grafana/pull/18933), [@papagian](https://github.com/papagian) +* **InfluxDB**: Enable interpolation within ad-hoc filter values. [#18077](https://github.com/grafana/grafana/pull/18077), [@kvc-code](https://github.com/kvc-code) +* **LDAP**: Allow an user to be synchronized against LDAP. [#18976](https://github.com/grafana/grafana/pull/18976), [@gotjosh](https://github.com/gotjosh) +* **Ldap**: Add ldap debug page. [#18759](https://github.com/grafana/grafana/pull/18759), [@peterholmberg](https://github.com/peterholmberg) +* **Loki**: Remove prefetching of default label values. [#18213](https://github.com/grafana/grafana/pull/18213), [@davkal](https://github.com/davkal) +* **Metrics**: Add failed alert notifications metric. [#18089](https://github.com/grafana/grafana/pull/18089), [@koorgoo](https://github.com/koorgoo) +* **OAuth**: Support JMES path lookup when retrieving user email. [#14683](https://github.com/grafana/grafana/pull/14683), [@bobmshannon](https://github.com/bobmshannon) +* **OAuth**: return GitLab groups as a part of user info (enable team sync). [#18388](https://github.com/grafana/grafana/pull/18388), [@alexanderzobnin](https://github.com/alexanderzobnin) +* **Panels**: Add unit for electrical charge - ampere-hour. [#18950](https://github.com/grafana/grafana/pull/18950), [@anirudh-ramesh](https://github.com/anirudh-ramesh) +* **Plugin**: AzureMonitor - Reapply MetricNamespace support. [#17282](https://github.com/grafana/grafana/pull/17282), [@raphaelquati](https://github.com/raphaelquati) +* **Plugins**: better warning when plugins fail to load. [#18671](https://github.com/grafana/grafana/pull/18671), [@ryantxu](https://github.com/ryantxu) +* **Postgres**: Add support for scram sha 256 authentication. [#18397](https://github.com/grafana/grafana/pull/18397), [@nonamef](https://github.com/nonamef) +* **RemoteCache**: Support SSL with Redis. [#18511](https://github.com/grafana/grafana/pull/18511), [@kylebrandt](https://github.com/kylebrandt) +* **SingleStat**: The gauge option in now disabled/hidden (unless it's an old panel with it already enabled) . [#18610](https://github.com/grafana/grafana/pull/18610), [@ryantxu](https://github.com/ryantxu) +* **Stackdriver**: Add extra alignment period options. [#18909](https://github.com/grafana/grafana/pull/18909), [@sunker](https://github.com/sunker) +* **Units**: Add South African Rand (ZAR) to currencies. [#18893](https://github.com/grafana/grafana/pull/18893), [@jeteon](https://github.com/jeteon) +* **Units**: Adding T,P,E,Z,and Y bytes. [#18706](https://github.com/grafana/grafana/pull/18706), [@chiqomar](https://github.com/chiqomar) + +### Bug Fixes +* **Alerting**: Notification is sent when state changes from no_data to ok. [#18920](https://github.com/grafana/grafana/pull/18920), [@papagian](https://github.com/papagian) +* **Alerting**: fix duplicate alert states when the alert fails to save to the database. [#18216](https://github.com/grafana/grafana/pull/18216), [@kylebrandt](https://github.com/kylebrandt) +* **Alerting**: fix response popover prompt when add notification channels. [#18967](https://github.com/grafana/grafana/pull/18967), [@lzdw](https://github.com/lzdw) +* **CloudWatch**: Fix alerting for queries with Id (using GetMetricData). [#17899](https://github.com/grafana/grafana/pull/17899), [@alex-berger](https://github.com/alex-berger) +* **Explore**: Fix auto completion on label values for Loki. [#18988](https://github.com/grafana/grafana/pull/18988), [@aocenas](https://github.com/aocenas) +* **Explore**: Fixes crash using back button with a zoomed in graph. [#19122](https://github.com/grafana/grafana/pull/19122), [@hugohaggmark](https://github.com/hugohaggmark) +* **Explore**: Fixes so queries in Explore are only run if Graph/Table is shown. [#19000](https://github.com/grafana/grafana/pull/19000), [@hugohaggmark](https://github.com/hugohaggmark) +* **MSSQL**: Change connectionstring to URL format to fix using passwords with semicolon. [#18384](https://github.com/grafana/grafana/pull/18384), [@Russiancold](https://github.com/Russiancold) +* **MSSQL**: Fix memory leak when debug enabled. [#19049](https://github.com/grafana/grafana/pull/19049), [@briangann](https://github.com/briangann) +* **Provisioning**: Allow escaping literal '$' with '$$' in configs to avoid interpolation. [#18045](https://github.com/grafana/grafana/pull/18045), [@kylebrandt](https://github.com/kylebrandt) +* **TimePicker**: Fixes hiding time picker dropdown in FireFox. [#19154](https://github.com/grafana/grafana/pull/19154), [@hugohaggmark](https://github.com/hugohaggmark) + +## Breaking changes + +### Annotations + +There are some breaking changes in the annotations HTTP API for region annotations. Region annotations are now represented +using a single event instead of two seperate events. Check breaking changes in HTTP API [below](#http-api) and [HTTP API documentation](https://grafana.com/docs/http_api/annotations/) for more details. + +### Docker + +Grafana is now using Alpine 3.10 as docker base image. + +### HTTP API + - `GET /api/alert-notifications` now requires at least editor access. New `/api/alert-notifications/lookup` returns less information than `/api/alert-notifications` and can be access by any authenticated user. + - `GET /api/alert-notifiers` now requires at least editor access + - `GET /api/org/users` now requires org admin role. New `/api/org/users/lookup` returns less information than `/api/org/users` and can be access by users that are org admins, admin in any folder or admin of any team. + - `GET /api/annotations` no longer returns `regionId` property. + - `POST /api/annotations` no longer supports `isRegion` property. + - `PUT /api/annotations/:id` no longer supports `isRegion` property. + - `PATCH /api/annotations/:id` no longer supports `isRegion` property. + - `DELETE /api/annotations/region/:id` has been removed. + +## Deprecation notes + +### PhantomJS + +[PhantomJS](https://phantomjs.org/), which is used for rendering images of dashboards and panels, is deprecated and will be removed in a future Grafana release. A deprecation warning will from now on be logged when Grafana starts up if PhantomJS is in use. -* **Annotations**: There are some breaking changes in the annotations HTTP API for region annotations. Region - annotations are now represented using a single event instead of two seperate events. Check HTTP docs for more details. +Please consider migrating from PhantomJS to the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). # 6.3.5 (2019-09-02) diff --git a/Dockerfile b/Dockerfile index fefa8ff96f857..9ee46ad89a925 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,7 +62,8 @@ ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bi WORKDIR $GF_PATHS_HOME -RUN apk add --no-cache ca-certificates bash +RUN apk add --no-cache ca-certificates bash && \ + apk add --no-cache --upgrade --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main openssl musl-utils COPY conf ./conf diff --git a/ISSUE_TRIAGE.md b/ISSUE_TRIAGE.md index 004419abe1214..cee5e8567eff0 100644 --- a/ISSUE_TRIAGE.md +++ b/ISSUE_TRIAGE.md @@ -183,6 +183,8 @@ If the author does not respond to the requested information within the timespan When you feel you have all the information needed you're ready to [categorizing the issue](#3-categorizing-an-issue). +If you receive a notification with additional information provided but you are not anymore on issue triage and you feel you do not have time to handle it, you should delegate it to the current person on issue triage. + ## 3. Categorizing an issue An issue can have multiple of the following labels. Typically, a properly categorized issue should at least have: diff --git a/conf/defaults.ini b/conf/defaults.ini index 52b9a4f2ed362..3bb57c3ac7201 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -593,8 +593,10 @@ enabled = true #################################### Internal Grafana Metrics ############ # Metrics available at HTTP API Url /metrics [metrics] -enabled = true -interval_seconds = 10 +enabled = true +interval_seconds = 10 +# Disable total stats (stat_totals_*) metrics to be generated +disable_total_stats = false #If both are set, basic auth will be required for the metrics endpoint. basic_auth_username = @@ -667,8 +669,10 @@ container_name = # does not require any configuration [rendering] -# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer +# Options to configure a remote HTTP image rendering service, e.g. using https://github.com/grafana/grafana-image-renderer. +# URL to a remote HTTP image renderer service, e.g. http://localhost:8081/render, will enable Grafana to render panels and dashboards to PNG-images using HTTP requests to an external service. server_url = +# If the remote HTTP image renderer service runs on a different server than the Grafana server you may have to configure this to a URL where Grafana is reachable, e.g. http://grafana.domain/. callback_url = [panels] diff --git a/conf/sample.ini b/conf/sample.ini index 31468bfa15bc8..7e8d96065b9c2 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -526,6 +526,8 @@ [metrics] # Disable / Enable internal metrics ;enabled = true +# Disable total stats (stat_totals_*) metrics to be generated +;disable_total_stats = false # Publish interval ;interval_seconds = 10 @@ -596,8 +598,10 @@ # does not require any configuration [rendering] -# Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer +# Options to configure a remote HTTP image rendering service, e.g. using https://github.com/grafana/grafana-image-renderer. +# URL to a remote HTTP image renderer service, e.g. http://localhost:8081/render, will enable Grafana to render panels and dashboards to PNG-images using HTTP requests to an external service. ;server_url = +# If the remote HTTP image renderer service runs on a different server than the Grafana server you may have to configure this to a URL where Grafana is reachable, e.g. http://grafana.domain/. ;callback_url = [enterprise] diff --git a/devenv/docker/blocks/slow_proxy_mac/Dockerfile b/devenv/docker/blocks/slow_proxy_mac/Dockerfile new file mode 100644 index 0000000000000..e553cb6727c6d --- /dev/null +++ b/devenv/docker/blocks/slow_proxy_mac/Dockerfile @@ -0,0 +1,7 @@ + +FROM golang:latest +ADD main.go / +WORKDIR / +RUN go build -o main . +EXPOSE 3011 +ENTRYPOINT ["/main"] diff --git a/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml b/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml new file mode 100644 index 0000000000000..47347042df7fe --- /dev/null +++ b/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml @@ -0,0 +1,6 @@ + slow_proxy_mac: + build: docker/blocks/slow_proxy_mac + ports: + - '3011:3011' + environment: + ORIGIN_SERVER: 'http://host.docker.internal:9090/' diff --git a/devenv/docker/blocks/slow_proxy_mac/main.go b/devenv/docker/blocks/slow_proxy_mac/main.go new file mode 100644 index 0000000000000..dece2525c1399 --- /dev/null +++ b/devenv/docker/blocks/slow_proxy_mac/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "time" +) + +func main() { + origin := os.Getenv("ORIGIN_SERVER") + if origin == "" { + origin = "http://host.docker.internal:9090/" + } + + sleep := time.Minute + + originURL, _ := url.Parse(origin) + proxy := httputil.NewSingleHostReverseProxy(originURL) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Printf("sleeping for %s then proxying request: %s", sleep.String(), r.RequestURI) + <-time.After(sleep) + proxy.ServeHTTP(w, r) + }) + + log.Fatal(http.ListenAndServe(":3011", nil)) +} diff --git a/docs/sources/administration/image_rendering.md b/docs/sources/administration/image_rendering.md new file mode 100644 index 0000000000000..1e1801e698741 --- /dev/null +++ b/docs/sources/administration/image_rendering.md @@ -0,0 +1,51 @@ ++++ +title = "Image Rendering" +description = "" +keywords = ["grafana", "image", "rendering", "phantomjs"] +type = "docs" +aliases = ["/installation/image-rendering"] +[menu.docs] +parent = "admin" +weight = 8 ++++ + +# Image Rendering + +Grafana supports rendering of panels and dasnhboards as PNG-images. + +When an image is being rendered the PNG-image is temporary written to the filesystem, i.e. a sub-directory of Grafana's [data](/installation/configuration/#data) directory named `png`. + +A background job runs each 10 minutes and will remove temporary images. You can configure how long time an image should be stored before being removed by configuring the [temp-data-lifetime](/installation/configuration/#temp-data-lifetime) setting. + +## Rendering methods + +### PhantomJS + +> PhantomJS is deprecated since Grafana v6.4 and will be removed in a future release. Please migrate to Grafana image renderer plugin or remote rendering service. + +[PhantomJS](https://phantomjs.org/) have been the only supported and default image renderer since Grafana v2.x and is shipped with Grafana. + +Please note that for OSX and Windows, you will need to ensure that a phantomjs binary is available under tools/phantomjs/phantomjs. For Linux, a phantomjs binary is included - however, you should ensure that any required libraries, e.g. libfontconfig1, are available. + +### Grafana image renderer plugin + +The [Grafana image renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) is a plugin that runs on the backend and handles rendering panels and dashboards as PNG-images using headless chrome. + +You can install it using grafana-cli: + +```bash +grafana-cli plugins install grafana-image-renderer +``` + +For further information and instructions refer to the [plugin details](https://grafana.com/grafana/plugins/grafana-image-renderer). + +### Remote rendering service + +The [Grafana image renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer) can also be run as a remote HTTP rendering service. In this setup Grafana will render an image by making a HTTP request to the remote rendering service, which in turn render the image and returns it back in the HTTP response to Grafana. + +To configure Grafana to use a remote HTTP rendering service, please refer to [rendering](/installation/configuration/#rendering) configuration section. + +## Alerting and render limits + +Alert notifications can include images, but rendering many images at the same time can overload the server where the renderer is running. For instructions of how to configure this, see [concurrent_render_limit](/installation/configuration/#concurrent-render-limit). + diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index 7ec53e402b165..e2231986eecee 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -190,7 +190,9 @@ Webhook | `webhook` | yes, external only | yes # Enable images in notifications {#external-image-store} -Grafana can render the panel associated with the alert rule and include that in the notification. Most Notification Channels require that this image be publicly accessible (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports +Grafana can render the panel associated with the alert rule as a PNG image and include that in the notification. Read more about the requirements and how to configure image rendering [here](/administration/image_rendering/). + +Most Notification Channels require that this image be publicly accessible (Slack and PagerDuty for example). In order to include images in alert notifications, Grafana can upload the image to an image store. It currently supports Amazon S3, Webdav, Google Cloud Storage and Azure Blob Storage. So to set that up you need to configure the [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini config file. Be aware that some notifiers requires public access to the image to be able to include it in the notification. So make sure to enable public access to the images. If you're using local image uploader, your Grafana instance need to be accessible by the internet. diff --git a/docs/sources/alerting/rules.md b/docs/sources/alerting/rules.md index 510f466c3eb35..eebe7382c1d74 100644 --- a/docs/sources/alerting/rules.md +++ b/docs/sources/alerting/rules.md @@ -51,7 +51,9 @@ Here you can specify the name of the alert rule and how often the scheduler shou ### For -> This setting is available in Grafana 5.4 and above. +> **Important note regarding No Data:** +> +> Do not use `For` with the `If no data or all values are null` setting set to `No Data`. The triggering of `No Data` will trigger instantly and not take `For` into consideration. This may also result in that an OK notification not being sent if alert transitions from `No Data -> Pending -> OK`. If an alert rule has a configured `For` and the query violates the configured threshold it will first go from `OK` to `Pending`. Going from `OK` to `Pending` Grafana will not send any notifications. Once the alert rule has been firing for more than `For` duration, it will change to `Alerting` and send alert notifications. diff --git a/docs/sources/contribute/documentation.md b/docs/sources/contribute/documentation.md new file mode 100644 index 0000000000000..391d996da4ec9 --- /dev/null +++ b/docs/sources/contribute/documentation.md @@ -0,0 +1,51 @@ ++++ +title = "Documentation" +description = "Contributing to documentation" +type = "docs" +[menu.docs] +parent = "contribute" +weight = 2 ++++ + +# Contributing to documentation + +## How do I contribute? + +If you’re unsure about where to start, check out some of our [open docs issues](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3Atype%2Fdocs). + +Sometimes it can be difficult to understand an issue when you're just getting started. We strive to keep a collection of [beginner-friendly issues](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3Atype%2Fdocs+label%3A"beginner+friendly") that is more suitable for first-time contributors. + +When you’ve found an issue you want to work on, you’re encouraged to comment on the issue to let other people know you intend to work on it. + +If you encounter any misspellings, or violations to the style guide, please let us know by submitting an issue. + +On every page in the documentation there are two links: + +- __Edit this page__ takes you directly to the file on GitHub where you can contribute a fix. +- __Request doc changes__ prepares an issue on GitHub with relevant information already filled in. + +## Community + +If you have questions on a specific issue, post a comment to ask for clarification, or to give feedback. + +For general discussions on documentation, you’re welcome to join the `#docs` channel on our [public Grafana Slack](http://slack.raintank.io) team. + +## Guidelines + +All Grafana documentation is written using [Markdown](https://en.wikipedia.org/wiki/Markdown), and can be found in the [docs](https://github.com/grafana/grafana/tree/master/docs) directory in the [Grafana GitHub repository](https://github.com/grafana/grafana). The [documentation website](https://grafana.com/docs) is generated with [hugo](https://gohugo.io) which uses [Blackfriday](https://github.com/russross/blackfriday) as its Markdown rendering engine. + +### Structure + +The documentation is organized into topics, called _sections_. + +Each top-level section is located under the [docs/sources](https://github.com/grafana/grafana/tree/master/docs/sources) directory. Subsections are added by creating a subdirectory in the directory of the parent section. + +For each section, a `_index.md` file is used to provide an overview of the topic. + +### Style guide + +The [codespell](https://github.com/codespell-project/codespell) tool is run for every change to catch common misspellings. + +- "Open source" should be hyphenated when used as an adjective, e.g. _open-source software_. The open form should be preferred when used as a noun, e.g. _Grafana is open source_. +- Use "data source" instead of "datasource" unless used as an identifier, in code or as part of URLs. +- Acronyms should be uppercased, e.g. URL, DNS, or TCP/IP. diff --git a/docs/sources/features/datasources/prometheus.md b/docs/sources/features/datasources/prometheus.md index 5ac3543cb3112..e0124d76f017e 100644 --- a/docs/sources/features/datasources/prometheus.md +++ b/docs/sources/features/datasources/prometheus.md @@ -25,16 +25,17 @@ Grafana includes built-in support for Prometheus. ## Data source options -| Name | Description | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| _Name_ | The data source name. This is how you refer to the data source in panels & queries. | -| _Default_ | Default data source means that it will be pre-selected for new panels. | -| _Url_ | The http protocol, ip and port of you Prometheus server (default port is usually 9090) | -| _Access_ | Server (default) = URL needs to be accessible from the Grafana backend/server, Browser = URL needs to be accessible from the browser. | -| _Basic Auth_ | Enable basic authentication to the Prometheus data source. | -| _User_ | Name of your Prometheus user | -| _Password_ | Database user's password | -| _Scrape interval_ | This will be used as a lower limit for the Prometheus step query parameter. Default value is 15s. | +| Name | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| _Name_ | The data source name. This is how you refer to the data source in panels & queries. | +| _Default_ | Default data source means that it will be pre-selected for new panels. | +| _Url_ | The http protocol, ip and port of you Prometheus server (default port is usually 9090) | +| _Access_ | Server (default) = URL needs to be accessible from the Grafana backend/server, Browser = URL needs to be accessible from the browser. | +| _Basic Auth_ | Enable basic authentication to the Prometheus data source. | +| _User_ | Name of your Prometheus user | +| _Password_ | Database user's password | +| _Scrape interval_ | This will be used as a lower limit for the Prometheus step query parameter. Default value is 15s. | +| _CustomQueryParameters_ | Add Custom parameters to Prometheus query url. For example `timeout`, `partial_response`, `dedup` or `max_source_resolution`. | ## Query editor diff --git a/docs/sources/features/panels/graph.md b/docs/sources/features/panels/graph.md index 33a51274454d1..37eac3aec81f6 100644 --- a/docs/sources/features/panels/graph.md +++ b/docs/sources/features/panels/graph.md @@ -210,6 +210,9 @@ available suggestions: #### Built-in variables +> These variables changed in 6.4 so if you have an older version of Grafana please use the version picker to select +docs for an older version of Grafana. + ``__url_time_range`` - current dashboard's time range (i.e. ``?from=now-6h&to=now``) ``__from`` - current dashboard's time range from value ``__to`` - current dashboard's time range to value @@ -240,7 +243,6 @@ Value specific variables are available under ``__value`` namespace: ``__value.calc`` - calculation name if the value is result of calculation - #### Template variables When linking to another dashboard that uses template variables, you can use ``var-myvar=${myvar}`` syntax (where ``myvar`` is a name of template variable) diff --git a/docs/sources/guides/whats-new-in-v6-4.md b/docs/sources/guides/whats-new-in-v6-4.md new file mode 100644 index 0000000000000..e105c641ea9e4 --- /dev/null +++ b/docs/sources/guides/whats-new-in-v6-4.md @@ -0,0 +1,147 @@ ++++ +title = "What's New in Grafana v6.4" +description = "Feature & improvement highlights for Grafana v6.4" +keywords = ["grafana", "new", "documentation", "6.4"] +type = "docs" +[menu.docs] +name = "Version 6.4" +identifier = "v6.4" +parent = "whatsnew" +weight = -15 ++++ + +# What's New in Grafana v6.4 + +For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) + +## Highlights + +Grafana 6.4 comes with a lot of new features and enhancements backed with tons of work around the data models and query execution that is going to enable powerful future capabilities. +Some of those new capabilities can already be seen in this release, like sharing query results between panels. + +- [**Explore:** Go back to dashboard (with query changes)]({{< relref "#go-back-to-dashboard-from-explore" >}}) +- [**Explore:** Live tailing improvements]({{< relref "#live-tailing-improvements" >}}) +- **Loki:** Show logs as annotations in dashboard graphs +- **Loki:** Use Loki in dashboard panels +- [**Panels:** New logs panel]({{< relref "#new-logs-panel" >}}) +- [**Panels:** Data links improvements]({{< relref "#data-links-improvements" >}}) +- **Graph:** Series override to turn constant (point) into a line +- [**Dashboard:** Share query results between panels]({{< relref "#share-query-results-between-panels" >}}) +- [**Plugins:** Alpha version of grafana-toolkit]({{< relref "#alpha-version-of-grafana-toolkit" >}}) +- [**Azure:** Query over multiple resources in Azure Monitor]({{< relref "#query-over-multiple-resources-in-azure-monitor" >}}) +- [**ImageRendering:** PhantomJS deprecation]({{< relref "#phantomjs-deprecation" >}}) +- [**Docker:** Alpine based docker image]({{< relref "#alpine-based-docker-image" >}}) +- [**LDAP:** Debug UI]({{< relref "#ldap-debug-ui" >}}) +- [**Enterprise**: Reporting]({{< relref "#reporting" >}}) +- [**Enterprise**: GitLab OAuth Team Sync support]({{< relref "#gitlab-oauth-team-sync-support" >}}) +- [**Enterprise**: Teams & LDAP Improvements]({{< relref "#ldap-teams" >}}) + + +### Go back to dashboard from Explore + +To help accelerate workflows that involve regularly switching from Explore to a dashboard and vice-versa, we've added the ability to return to the origin dashboard +after navigating to Explore from the panel's dropdown. + +{{< docs-imagebox img="/img/docs/v60/explore_panel_menu.png" caption="Screenshot of the new Explore Icon" >}} + +After you've navigated to Explore, you should notice a "Back" button in the Explore toolbar. + + + +Simply clicking the button will return you to the origin dashboard, or, if you'd like to bring changes you make in Explore back to the dashboard, simply click +the arrow next to the button to reveal a "Return to panel with changes" menu item. + + + +### Live tailing improvements + +With 6.4 version you can now pause the live tail view to see the last 1000 lines of logs without being interrupted by new logs coming in. You can either pause manually with pause button or the live tailing will automatically pause when you scroll up to see older logs. To resume you just hit the resume button to continue live tailing. + +We also introduced some performance optimizations to allow live tailing of higher throughput log streams and various UI fixes and improvements like more consistent styling and fresh logs highlighting. + + + +### New Logs Panel + +A new panel specifically to show logs is added in this release. It’s in early alpha state so you have to enable alpha panels (in config file) to be able to try it out. + +## Data Links improvements + +With Grafana 6.3 we introduced a new way of creating [Data Links](https://grafana.com/blog/2019/08/27/new-in-grafana-6.3-easy-to-use-data-links/). +Grafana 6.4 improves Data Links and adds them to the Gauge and Bar Gauge and panels. + +With Data Links you can define dynamic links to other dashboards and systems. The link can now reference template variables and query results like series name & labels, field name, value and time. + +Read more about Data Links and what you can do with them in [documentation](https://grafana.com/docs/features/panels/graph/#data-link) + +## Share query results between panels + +Grafana 6.4 continues the work started in 6.3 of creating a data model and query execution lifecycle that can support robust analytics and streaming. These changes are mostly structural and lay the foundation for powerful features in future releases. + +The first new feature all these changes have enabled is the ability to share query results between panels. So for example if you have an expensive query you can visualize the same results in a graph, table and singlestat panel. To reuse another panel’s query result select the data source named `-- Dashboard --` and then select the panel. + +To make the sharing of query results even more powerful we are introducing a transformation step as well that allows you to select specific parts of the query result and transform it. This new transformation feature is in [alpha](https://grafana.com/docs/installation/configuration/#enable-alpha) state and has to be enabled in the config file. + +DataFrame, our primary data model, has now a [columnar](https://en.wikipedia.org/wiki/Column-oriented_DBMS) layout. This +will support easier frontend processing. The DataSource query interface has been updated to better support streaming. +The result can now either return a `Promise` or `Observable`. Be on the lookout for more on live data +streaming in the future! + +## Alpha version of grafana-toolkit + +[grafana-toolkit](https://www.npmjs.com/package/@grafana/toolkit/v/6.4.0-beta.1) is our attempt to simplify the life of plugin developers. It’s a CLI that helps them focus on the core value of their plugin rather than the ceremony around setting up the environment, configs, tests and builds. It’s available as an NPM package under `next` tag. + +You can read more about the grafana-toolkit [in the Readme](https://github.com/grafana/grafana/blob/master/packages/grafana-toolkit/README.md) and play with it by trying out our [react panel](https://github.com/grafana/simple-react-panel) or [angular panel](https://github.com/grafana/simple-angular-panel) templates. + +## Query over multiple resources in Azure Monitor + +Up until now it has only been possible to query over one resource in one subscription in the Azure Monitor datasource. In Grafana 6.4, the Azure Monitor query editor contains a new option called Query Mode which allows for querying over multiple resources in multiple subscriptions. This can be very useful in many situations, e.g cpu credits consumed by all virtual machines over many subscriptions. + +Alerting is yet to be implemented for Multiple Resources queries. + +## PhantomJS deprecation + +[PhantomJS](https://phantomjs.org/), which is used for rendering images of dashboards and panels, have been deprecated and will be removed in a future Grafana release. A deprecation warning will from now on be logged when Grafana starts up if PhantomJS is in use. + +Please consider migrating from PhantomJS to the [Grafana Image Renderer plugin](https://grafana.com/grafana/plugins/grafana-image-renderer). + +## Alpine based docker image + +Grafana’s docker image is now based on Alpine 3.10 and should from now on report zero vulnerabilities when scanning the image for security vulnerabilities. + +## LDAP Debug UI + +After listening to customer feedback, we have been working at improving the experience to set up authentication and synchronization with LDAP. We're happy to present the new LDAP Debug View. + +You'll be able to see how a user authenticating with LDAP would be mapped and whether your LDAP integration is working correctly. Furthermore, it provides a simpler method to test your integration with LDAP server(s) and have a clear view of how attributes are mapped between both systems. + +The feature is currently limited to Grafana Server Admins. + +For more information on how to use this new feature, follow the [guide](TBD). + +## Grafana Enterprise + +### Reporting + +A common request from Enterprise users have been to be able to set up reporting for Grafana, and now it’s here. A report is simply a PDF of a Grafana dashboard, outside of just generating a PDF you can set up a schedule so that you can get the report emailed to yourself (or whoever is interested) whenever it suits you. + +This feature is currently limited to Organization Admins. + +{{< docs-imagebox img="/img/docs/v64/reports.jpeg" max-width="500px" caption="Reporting" >}} + +### GitLab OAuth Team Sync support + +GitLab OAuth gets support for Team Sync, making it possible to synchronize your GitLab Groups with Teams in Grafana. + +[Read more about Team Sync](https://grafana.com/docs/auth/team-sync/) + +## Upgrading + +See [upgrade notes](/docs/installation/upgrading/#upgrading-to-v6-4). + +## Changelog + +Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list of new features, changes, and bug fixes. + + + diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 3c4eceaaab68f..ade8cb296ce5a 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -548,6 +548,9 @@ If set configures the username to use for basic authentication on the metrics en ### basic_auth_password If set configures the password to use for basic authentication on the metrics endpoint. +### disable_total_stats +If set to `true`, then total stats generation (`stat_totals_*` metrics) is disabled. The default is `false`. + ### interval_seconds Flush/Write interval when sending metrics to external TSDB. Defaults to 10s. @@ -685,6 +688,17 @@ Default setting for alert notification timeout. Default value is `30` Default setting for max attempts to sending alert notifications. Default value is `3` +## [rendering] + +Options to configure a remote HTTP image rendering service, e.g. using https://github.com/grafana/grafana-image-renderer. + +### server_url + +URL to a remote HTTP image renderer service, e.g. http://localhost:8081/render, will enable Grafana to render panels and dashboards to PNG-images using HTTP requests to an external service. + +### callback_url + +If the remote HTTP image renderer service runs on a different server than the Grafana server you may have to configure this to a URL where Grafana is reachable, e.g. http://grafana.domain/. ## [panels] @@ -699,6 +713,11 @@ is false. This settings was introduced in Grafana v6.0. Set to true if you want to test alpha plugins that are not yet ready for general usage. +## [feature_toggles] +### enable + +Keys of alpha features to enable, separated by space. Available alpha features are: `transformations` +
# Removed options diff --git a/docs/sources/reference/sharing.md b/docs/sources/reference/sharing.md index aebf76090f3a3..7f9599d004064 100644 --- a/docs/sources/reference/sharing.md +++ b/docs/sources/reference/sharing.md @@ -39,12 +39,12 @@ Click a panel title to open the panel menu, then click share in the panel menu t ### Direct Link Rendered Image -You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `tools/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig1) are available. +You also get a link to render a PNG image of the panel. Useful if you want to share an image of the panel. Read more about the requirements and how to configure image rendering [here](/administration/image_rendering/). Example of a link to a server-side rendered PNG: ```bash -http://play.grafana.org/render/dashboard-solo/db/grafana-play-home?orgId=1&panelId=4&from=1499272191563&to=1499279391563&width=1000&height=500&tz=UTC%2B02%3A00&timeout=5000 +https://play.grafana.org/d/000000012/grafana-play-home?orgId=1&from=1568719680173&to=1568726880174&panelId=4&fullscreen ``` #### Query String Parameters For Server-Side Rendered Images diff --git a/latest.json b/latest.json index 40d26f46fe2dc..b5abed00efaf0 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { "stable": "6.3.5", - "testing": "6.3.5" + "testing": "6.4.0-beta1" } diff --git a/lerna.json b/lerna.json index 30f4e4728fd69..28adbc5e7544a 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "npmClient": "yarn", "useWorkspaces": true, "packages": ["packages/*"], - "version": "6.4.0-pre" + "version": "6.5.0-pre" } diff --git a/package.json b/package.json index 2e6792aaee971..381021a34533b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "license": "Apache-2.0", "private": true, "name": "grafana", - "version": "6.4.0-pre", + "version": "6.5.0-pre", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" @@ -26,6 +26,7 @@ "@types/enzyme-adapter-react-16": "1.0.5", "@types/expect-puppeteer": "3.3.1", "@types/file-saver": "2.0.1", + "@types/hoist-non-react-statics": "3.3.0", "@types/is-hotkey": "0.1.1", "@types/jest": "24.0.13", "@types/jquery": "1.10.35", @@ -208,6 +209,7 @@ "eventemitter3": "2.0.3", "fast-text-encoding": "^1.0.0", "file-saver": "1.3.8", + "hoist-non-react-statics": "3.3.0", "immutable": "3.8.2", "is-hotkey": "0.1.4", "jquery": "3.4.1", diff --git a/packages/grafana-data/src/dataframe/CircularDataFrame.ts b/packages/grafana-data/src/dataframe/CircularDataFrame.ts new file mode 100644 index 0000000000000..890be6c2f50b8 --- /dev/null +++ b/packages/grafana-data/src/dataframe/CircularDataFrame.ts @@ -0,0 +1,22 @@ +import { MutableDataFrame } from './MutableDataFrame'; +import { CircularVector } from '../vector/CircularVector'; + +interface CircularOptions { + append?: 'head' | 'tail'; + capacity?: number; +} + +/** + * This dataframe can have values constantly added, and will never + * exceed the given capacity + */ +export class CircularDataFrame extends MutableDataFrame { + constructor(options: CircularOptions) { + super(undefined, (buffer?: any[]) => { + return new CircularVector({ + ...options, + buffer, + }); + }); + } +} diff --git a/packages/grafana-data/src/utils/dataFrameView.test.ts b/packages/grafana-data/src/dataframe/DataFrameView.test.ts similarity index 90% rename from packages/grafana-data/src/utils/dataFrameView.test.ts rename to packages/grafana-data/src/dataframe/DataFrameView.test.ts index 8755cfc827a9b..eb182e60c396a 100644 --- a/packages/grafana-data/src/utils/dataFrameView.test.ts +++ b/packages/grafana-data/src/dataframe/DataFrameView.test.ts @@ -1,7 +1,7 @@ -import { FieldType, DataFrameDTO } from '../types/index'; -import { MutableDataFrame } from './dataFrameHelper'; -import { DataFrameView } from './dataFrameView'; -import { DateTime } from './moment_wrapper'; +import { FieldType, DataFrameDTO } from '../types/dataFrame'; +import { DateTime } from '../datetime/moment_wrapper'; +import { MutableDataFrame } from './MutableDataFrame'; +import { DataFrameView } from './DataFrameView'; interface MySpecialObject { time: DateTime; diff --git a/packages/grafana-data/src/utils/dataFrameView.ts b/packages/grafana-data/src/dataframe/DataFrameView.ts similarity index 95% rename from packages/grafana-data/src/utils/dataFrameView.ts rename to packages/grafana-data/src/dataframe/DataFrameView.ts index 1cf833e9dedc6..cbb0106183c1b 100644 --- a/packages/grafana-data/src/utils/dataFrameView.ts +++ b/packages/grafana-data/src/dataframe/DataFrameView.ts @@ -1,4 +1,5 @@ -import { DataFrame, Vector } from '../types/index'; +import { Vector } from '../types/vector'; +import { DataFrame } from '../types/dataFrame'; /** * This abstraction will present the contents of a DataFrame as if diff --git a/packages/grafana-data/src/utils/dataFrameHelper.test.ts b/packages/grafana-data/src/dataframe/FieldCache.test.ts similarity index 56% rename from packages/grafana-data/src/utils/dataFrameHelper.test.ts rename to packages/grafana-data/src/dataframe/FieldCache.test.ts index 5555c09cd4011..d38946bd712fc 100644 --- a/packages/grafana-data/src/utils/dataFrameHelper.test.ts +++ b/packages/grafana-data/src/dataframe/FieldCache.test.ts @@ -1,30 +1,7 @@ -import { DataFrameDTO, FieldType } from '../types'; -import { FieldCache, MutableDataFrame } from './dataFrameHelper'; +import { FieldCache } from './FieldCache'; +import { FieldType } from '../types/dataFrame'; import { toDataFrame } from './processDataFrame'; -describe('dataFrameHelper', () => { - const frame = toDataFrame({ - fields: [ - { name: 'time', type: FieldType.time, values: [100, 200, 300] }, - { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, - { name: 'value', type: FieldType.number, values: [1, 2, 3] }, - { name: 'value', type: FieldType.number, values: [4, 5, 6] }, - ], - }); - const ext = new FieldCache(frame); - - it('should get the first field with a duplicate name', () => { - const field = ext.getFieldByName('value'); - expect(field!.name).toEqual('value'); - expect(field!.values.toJSON()).toEqual([1, 2, 3]); - }); - - it('should return index of the field', () => { - const field = ext.getFirstFieldOfType(FieldType.number); - expect(field!.index).toEqual(2); - }); -}); - describe('FieldCache', () => { it('when creating a new FieldCache from fields should be able to query cache', () => { const frame = toDataFrame({ @@ -90,68 +67,27 @@ describe('FieldCache', () => { expect(fieldCache.getFieldByName('undefined')!.name).toEqual(expectedFieldNames[5]); expect(fieldCache.getFieldByName('null')).toBeUndefined(); }); -}); - -describe('reverse', () => { - describe('when called with a DataFrame', () => { - it('then it should reverse the order of values in all fields', () => { - const frame: DataFrameDTO = { - fields: [ - { name: 'time', type: FieldType.time, values: [100, 200, 300] }, - { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, - { name: 'value', type: FieldType.number, values: [1, 2, 3] }, - ], - }; - - const helper = new MutableDataFrame(frame); - - expect(helper.values.time.toArray()).toEqual([100, 200, 300]); - expect(helper.values.name.toArray()).toEqual(['a', 'b', 'c']); - expect(helper.values.value.toArray()).toEqual([1, 2, 3]); - - helper.reverse(); - - expect(helper.values.time.toArray()).toEqual([300, 200, 100]); - expect(helper.values.name.toArray()).toEqual(['c', 'b', 'a']); - expect(helper.values.value.toArray()).toEqual([3, 2, 1]); - }); - }); -}); -describe('Apending DataFrame', () => { - it('Should append values', () => { - const dto: DataFrameDTO = { + describe('field retrieval', () => { + const frame = toDataFrame({ fields: [ - { name: 'time', type: FieldType.time, values: [100] }, - { name: 'name', type: FieldType.string, values: ['a', 'b'] }, + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + { name: 'value', type: FieldType.number, values: [4, 5, 6] }, ], - }; - - const frame = new MutableDataFrame(dto); - expect(frame.values.time.toArray()).toEqual([100, null, null]); - - // Set a value on the second row - frame.set(1, { time: 200, name: 'BB', value: 20 }); - expect(frame.toArray()).toEqual([ - { time: 100, name: 'a', value: 1 }, // 1 - { time: 200, name: 'BB', value: 20 }, // 2 - { time: null, name: null, value: 3 }, // 3 - ]); + }); + const ext = new FieldCache(frame); - // Set a value on the second row - frame.add({ value2: 'XXX' }, true); - expect(frame.toArray()).toEqual([ - { time: 100, name: 'a', value: 1, value2: null }, // 1 - { time: 200, name: 'BB', value: 20, value2: null }, // 2 - { time: null, name: null, value: 3, value2: null }, // 3 - { time: null, name: null, value: null, value2: 'XXX' }, // 4 - ]); + it('should get the first field with a duplicate name', () => { + const field = ext.getFieldByName('value'); + expect(field!.name).toEqual('value'); + expect(field!.values.toJSON()).toEqual([1, 2, 3]); + }); - // Make sure length survives a spread operator - const keys = Object.keys(frame); - const copy = { ...frame } as any; - expect(keys).toContain('length'); - expect(copy.length).toEqual(frame.length); + it('should return index of the field', () => { + const field = ext.getFirstFieldOfType(FieldType.number); + expect(field!.index).toEqual(2); + }); }); }); diff --git a/packages/grafana-data/src/dataframe/FieldCache.ts b/packages/grafana-data/src/dataframe/FieldCache.ts new file mode 100644 index 0000000000000..07f6941e21dea --- /dev/null +++ b/packages/grafana-data/src/dataframe/FieldCache.ts @@ -0,0 +1,78 @@ +import { Field, DataFrame, FieldType, guessFieldTypeForField } from '../index'; + +interface FieldWithIndex extends Field { + index: number; +} + +export class FieldCache { + fields: FieldWithIndex[] = []; + + private fieldByName: { [key: string]: FieldWithIndex } = {}; + private fieldByType: { [key: string]: FieldWithIndex[] } = {}; + + constructor(data: DataFrame) { + this.fields = data.fields.map((field, idx) => ({ + ...field, + index: idx, + })); + + for (let i = 0; i < data.fields.length; i++) { + const field = data.fields[i]; + // Make sure it has a type + if (field.type === FieldType.other) { + const t = guessFieldTypeForField(field); + if (t) { + field.type = t; + } + } + if (!this.fieldByType[field.type]) { + this.fieldByType[field.type] = []; + } + this.fieldByType[field.type].push({ + ...field, + index: i, + }); + + if (this.fieldByName[field.name]) { + console.warn('Duplicate field names in DataFrame: ', field.name); + } else { + this.fieldByName[field.name] = { ...field, index: i }; + } + } + } + + getFields(type?: FieldType): FieldWithIndex[] { + if (!type) { + return [...this.fields]; // All fields + } + const fields = this.fieldByType[type]; + if (fields) { + return [...fields]; + } + return []; + } + + hasFieldOfType(type: FieldType): boolean { + const types = this.fieldByType[type]; + return types && types.length > 0; + } + + getFirstFieldOfType(type: FieldType): FieldWithIndex | undefined { + const arr = this.fieldByType[type]; + if (arr && arr.length > 0) { + return arr[0]; + } + return undefined; + } + + hasFieldNamed(name: string): boolean { + return !!this.fieldByName[name]; + } + + /** + * Returns the first field with the given name. + */ + getFieldByName(name: string): FieldWithIndex | undefined { + return this.fieldByName[name]; + } +} diff --git a/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts new file mode 100644 index 0000000000000..eb6f8aaee432f --- /dev/null +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.test.ts @@ -0,0 +1,66 @@ +import { DataFrameDTO, FieldType } from '../types/dataFrame'; +import { MutableDataFrame } from './MutableDataFrame'; + +describe('Reversing DataFrame', () => { + describe('when called with a DataFrame', () => { + it('then it should reverse the order of values in all fields', () => { + const frame: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100, 200, 300] }, + { name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + + const helper = new MutableDataFrame(frame); + + expect(helper.values.time.toArray()).toEqual([100, 200, 300]); + expect(helper.values.name.toArray()).toEqual(['a', 'b', 'c']); + expect(helper.values.value.toArray()).toEqual([1, 2, 3]); + + helper.reverse(); + + expect(helper.values.time.toArray()).toEqual([300, 200, 100]); + expect(helper.values.name.toArray()).toEqual(['c', 'b', 'a']); + expect(helper.values.value.toArray()).toEqual([3, 2, 1]); + }); + }); +}); + +describe('Apending DataFrame', () => { + it('Should append values', () => { + const dto: DataFrameDTO = { + fields: [ + { name: 'time', type: FieldType.time, values: [100] }, + { name: 'name', type: FieldType.string, values: ['a', 'b'] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }; + + const frame = new MutableDataFrame(dto); + expect(frame.values.time.toArray()).toEqual([100, null, null]); + + // Set a value on the second row + frame.set(1, { time: 200, name: 'BB', value: 20 }); + expect(frame.toArray()).toEqual([ + { time: 100, name: 'a', value: 1 }, // 1 + { time: 200, name: 'BB', value: 20 }, // 2 + { time: null, name: null, value: 3 }, // 3 + ]); + + // Set a value on the second row + frame.add({ value2: 'XXX' }, true); + expect(frame.toArray()).toEqual([ + { time: 100, name: 'a', value: 1, value2: null }, // 1 + { time: 200, name: 'BB', value: 20, value2: null }, // 2 + { time: null, name: null, value: 3, value2: null }, // 3 + { time: null, name: null, value: null, value2: 'XXX' }, // 4 + ]); + + // Make sure length survives a spread operator + const keys = Object.keys(frame); + const copy = { ...frame } as any; + expect(keys).toContain('length'); + expect(copy.length).toEqual(frame.length); + }); +}); diff --git a/packages/grafana-data/src/utils/dataFrameHelper.ts b/packages/grafana-data/src/dataframe/MutableDataFrame.ts similarity index 66% rename from packages/grafana-data/src/utils/dataFrameHelper.ts rename to packages/grafana-data/src/dataframe/MutableDataFrame.ts index 53eff25aaa05a..9ff9aa0f3aefb 100644 --- a/packages/grafana-data/src/utils/dataFrameHelper.ts +++ b/packages/grafana-data/src/dataframe/MutableDataFrame.ts @@ -1,111 +1,12 @@ -import { Field, FieldType, DataFrame, Vector, FieldDTO, DataFrameDTO } from '../types/dataFrame'; -import { Labels, QueryResultMeta, KeyValue } from '../types/data'; -import { guessFieldTypeForField, guessFieldTypeFromValue, toDataFrameDTO } from './processDataFrame'; -import { ArrayVector, MutableVector, vectorToArray, CircularVector } from './vector'; +import { Field, DataFrame, DataFrameDTO, FieldDTO, FieldType } from '../types/dataFrame'; +import { KeyValue, QueryResultMeta, Labels } from '../types/data'; +import { guessFieldTypeFromValue, guessFieldTypeForField, toDataFrameDTO } from './processDataFrame'; import isArray from 'lodash/isArray'; import isString from 'lodash/isString'; - -interface FieldWithIndex extends Field { - index: number; -} -export class FieldCache { - fields: FieldWithIndex[] = []; - - private fieldByName: { [key: string]: FieldWithIndex } = {}; - private fieldByType: { [key: string]: FieldWithIndex[] } = {}; - - constructor(data: DataFrame) { - this.fields = data.fields.map((field, idx) => ({ - ...field, - index: idx, - })); - - for (let i = 0; i < data.fields.length; i++) { - const field = data.fields[i]; - // Make sure it has a type - if (field.type === FieldType.other) { - const t = guessFieldTypeForField(field); - if (t) { - field.type = t; - } - } - if (!this.fieldByType[field.type]) { - this.fieldByType[field.type] = []; - } - this.fieldByType[field.type].push({ - ...field, - index: i, - }); - - if (this.fieldByName[field.name]) { - console.warn('Duplicate field names in DataFrame: ', field.name); - } else { - this.fieldByName[field.name] = { ...field, index: i }; - } - } - } - - getFields(type?: FieldType): FieldWithIndex[] { - if (!type) { - return [...this.fields]; // All fields - } - const fields = this.fieldByType[type]; - if (fields) { - return [...fields]; - } - return []; - } - - hasFieldOfType(type: FieldType): boolean { - const types = this.fieldByType[type]; - return types && types.length > 0; - } - - getFirstFieldOfType(type: FieldType): FieldWithIndex | undefined { - const arr = this.fieldByType[type]; - if (arr && arr.length > 0) { - return arr[0]; - } - return undefined; - } - - hasFieldNamed(name: string): boolean { - return !!this.fieldByName[name]; - } - - /** - * Returns the first field with the given name. - */ - getFieldByName(name: string): FieldWithIndex | undefined { - return this.fieldByName[name]; - } -} - -function makeFieldParser(value: any, field: Field): (value: string) => any { - if (!field.type) { - if (field.name === 'time' || field.name === 'Time') { - field.type = FieldType.time; - } else { - field.type = guessFieldTypeFromValue(value); - } - } - - if (field.type === FieldType.number) { - return (value: string) => { - return parseFloat(value); - }; - } - - // Will convert anything that starts with "T" to true - if (field.type === FieldType.boolean) { - return (value: string) => { - return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0'); - }; - } - - // Just pass the string back - return (value: string) => value; -} +import { makeFieldParser } from '../utils/fieldParser'; +import { MutableVector, Vector } from '../types/vector'; +import { ArrayVector } from '../vector/ArrayVector'; +import { vectorToArray } from '../vector/vectorToArray'; export type MutableField = Field>; @@ -380,23 +281,3 @@ export class MutableDataFrame implements DataFrame, MutableVector { return toDataFrameDTO(this); } } - -interface CircularOptions { - append?: 'head' | 'tail'; - capacity?: number; -} - -/** - * This dataframe can have values constantly added, and will never - * exceed the given capacity - */ -export class CircularDataFrame extends MutableDataFrame { - constructor(options: CircularOptions) { - super(undefined, (buffer?: any[]) => { - return new CircularVector({ - ...options, - buffer, - }); - }); - } -} diff --git a/packages/grafana-data/src/dataframe/index.ts b/packages/grafana-data/src/dataframe/index.ts new file mode 100644 index 0000000000000..e2cfc615ce434 --- /dev/null +++ b/packages/grafana-data/src/dataframe/index.ts @@ -0,0 +1,5 @@ +export * from './DataFrameView'; +export * from './FieldCache'; +export * from './CircularDataFrame'; +export * from './MutableDataFrame'; +export * from './processDataFrame'; diff --git a/packages/grafana-data/src/utils/processDataFrame.test.ts b/packages/grafana-data/src/dataframe/processDataFrame.test.ts similarity index 98% rename from packages/grafana-data/src/utils/processDataFrame.test.ts rename to packages/grafana-data/src/dataframe/processDataFrame.test.ts index a71dff6e9ca09..adf10690ec5a1 100644 --- a/packages/grafana-data/src/utils/processDataFrame.test.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.test.ts @@ -8,8 +8,8 @@ import { sortDataFrame, } from './processDataFrame'; import { FieldType, TimeSeries, TableData, DataFrameDTO } from '../types/index'; -import { dateTime } from './moment_wrapper'; -import { MutableDataFrame } from './dataFrameHelper'; +import { dateTime } from '../datetime/moment_wrapper'; +import { MutableDataFrame } from './MutableDataFrame'; describe('toDataFrame', () => { it('converts timeseries to series', () => { diff --git a/packages/grafana-data/src/utils/processDataFrame.ts b/packages/grafana-data/src/dataframe/processDataFrame.ts similarity index 97% rename from packages/grafana-data/src/utils/processDataFrame.ts rename to packages/grafana-data/src/dataframe/processDataFrame.ts index e88bb54ddecc6..6221bb4e658bd 100644 --- a/packages/grafana-data/src/utils/processDataFrame.ts +++ b/packages/grafana-data/src/dataframe/processDataFrame.ts @@ -17,10 +17,11 @@ import { FieldDTO, DataFrameDTO, } from '../types/index'; -import { isDateTime } from './moment_wrapper'; -import { ArrayVector, SortedVector } from './vector'; -import { MutableDataFrame } from './dataFrameHelper'; -import { deprecationWarning } from './deprecationWarning'; +import { isDateTime } from '../datetime/moment_wrapper'; +import { deprecationWarning } from '../utils/deprecationWarning'; +import { ArrayVector } from '../vector/ArrayVector'; +import { MutableDataFrame } from './MutableDataFrame'; +import { SortedVector } from '../vector/SortedVector'; function convertTableToDataFrame(table: TableData): DataFrame { const fields = table.columns.map(c => { diff --git a/packages/grafana-data/src/utils/datemath.test.ts b/packages/grafana-data/src/datetime/datemath.test.ts similarity index 98% rename from packages/grafana-data/src/utils/datemath.test.ts rename to packages/grafana-data/src/datetime/datemath.test.ts index 6ffd9194d7f38..3443eea2d1729 100644 --- a/packages/grafana-data/src/utils/datemath.test.ts +++ b/packages/grafana-data/src/datetime/datemath.test.ts @@ -2,7 +2,7 @@ import sinon, { SinonFakeTimers } from 'sinon'; import each from 'lodash/each'; import * as dateMath from './datemath'; -import { dateTime, DurationUnit, DateTime } from '../utils/moment_wrapper'; +import { dateTime, DurationUnit, DateTime } from './moment_wrapper'; describe('DateMath', () => { const spans: DurationUnit[] = ['s', 'm', 'h', 'd', 'w', 'M', 'y']; diff --git a/packages/grafana-data/src/utils/datemath.ts b/packages/grafana-data/src/datetime/datemath.ts similarity index 99% rename from packages/grafana-data/src/utils/datemath.ts rename to packages/grafana-data/src/datetime/datemath.ts index 47fe47282c3e9..29747e15bc722 100644 --- a/packages/grafana-data/src/utils/datemath.ts +++ b/packages/grafana-data/src/datetime/datemath.ts @@ -1,7 +1,7 @@ import includes from 'lodash/includes'; import isDate from 'lodash/isDate'; import { DateTime, dateTime, dateTimeForTimeZone, ISO_8601, isDateTime, DurationUnit } from './moment_wrapper'; -import { TimeZone } from '../types'; +import { TimeZone } from '../types/index'; const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's']; diff --git a/packages/grafana-data/src/datetime/index.ts b/packages/grafana-data/src/datetime/index.ts new file mode 100644 index 0000000000000..bbaa4ebd95cb0 --- /dev/null +++ b/packages/grafana-data/src/datetime/index.ts @@ -0,0 +1,5 @@ +// Names are too general to export globally +import * as dateMath from './datemath'; +import * as rangeUtil from './rangeutil'; +export * from './moment_wrapper'; +export { dateMath, rangeUtil }; diff --git a/packages/grafana-data/src/utils/moment_wrapper.ts b/packages/grafana-data/src/datetime/moment_wrapper.ts similarity index 100% rename from packages/grafana-data/src/utils/moment_wrapper.ts rename to packages/grafana-data/src/datetime/moment_wrapper.ts diff --git a/packages/grafana-data/src/utils/rangeutil.ts b/packages/grafana-data/src/datetime/rangeutil.ts similarity index 98% rename from packages/grafana-data/src/utils/rangeutil.ts rename to packages/grafana-data/src/datetime/rangeutil.ts index d8eb04ffd07d3..fcf55b37f4df0 100644 --- a/packages/grafana-data/src/utils/rangeutil.ts +++ b/packages/grafana-data/src/datetime/rangeutil.ts @@ -4,7 +4,7 @@ import groupBy from 'lodash/groupBy'; import { RawTimeRange } from '../types/time'; import * as dateMath from './datemath'; -import { isDateTime, DateTime } from '../utils/moment_wrapper'; +import { isDateTime, DateTime } from './moment_wrapper'; const spans: { [key: string]: { display: string; section?: number } } = { s: { display: 'second' }, diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 53f3ef7101117..c5f3add1d5b57 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -1,2 +1,7 @@ -export * from './utils/index'; -export * from './types/index'; +export * from './utils'; +export * from './types'; +export * from './vector'; +export * from './dataframe'; +export * from './transformations'; +export * from './datetime'; +export * from './text'; diff --git a/packages/grafana-data/src/text/index.ts b/packages/grafana-data/src/text/index.ts new file mode 100644 index 0000000000000..b59a1d992d5cf --- /dev/null +++ b/packages/grafana-data/src/text/index.ts @@ -0,0 +1,3 @@ +export * from './string'; +export * from './markdown'; +export * from './text'; diff --git a/packages/grafana-data/src/utils/markdown.test.ts b/packages/grafana-data/src/text/markdown.test.ts similarity index 100% rename from packages/grafana-data/src/utils/markdown.test.ts rename to packages/grafana-data/src/text/markdown.test.ts diff --git a/packages/grafana-data/src/utils/markdown.ts b/packages/grafana-data/src/text/markdown.ts similarity index 100% rename from packages/grafana-data/src/utils/markdown.ts rename to packages/grafana-data/src/text/markdown.ts diff --git a/packages/grafana-data/src/utils/string.test.ts b/packages/grafana-data/src/text/string.test.ts similarity index 100% rename from packages/grafana-data/src/utils/string.test.ts rename to packages/grafana-data/src/text/string.test.ts diff --git a/packages/grafana-data/src/utils/string.ts b/packages/grafana-data/src/text/string.ts similarity index 100% rename from packages/grafana-data/src/utils/string.ts rename to packages/grafana-data/src/text/string.ts diff --git a/packages/grafana-data/src/utils/text.test.ts b/packages/grafana-data/src/text/text.test.ts similarity index 100% rename from packages/grafana-data/src/utils/text.test.ts rename to packages/grafana-data/src/text/text.test.ts diff --git a/packages/grafana-data/src/utils/text.ts b/packages/grafana-data/src/text/text.ts similarity index 100% rename from packages/grafana-data/src/utils/text.ts rename to packages/grafana-data/src/text/text.ts diff --git a/packages/grafana-data/src/utils/fieldReducer.test.ts b/packages/grafana-data/src/transformations/fieldReducer.test.ts similarity index 96% rename from packages/grafana-data/src/utils/fieldReducer.test.ts rename to packages/grafana-data/src/transformations/fieldReducer.test.ts index c4bb1f1e41498..0dfc1d349a1ed 100644 --- a/packages/grafana-data/src/utils/fieldReducer.test.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.test.ts @@ -3,9 +3,9 @@ import difference from 'lodash/difference'; import { fieldReducers, ReducerID, reduceField } from './fieldReducer'; import { Field, FieldType } from '../types/index'; -import { MutableDataFrame } from './dataFrameHelper'; -import { ArrayVector } from './vector'; -import { guessFieldTypeFromValue } from './processDataFrame'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; +import { ArrayVector } from '../vector/ArrayVector'; /** * Run a reducer and get back the value diff --git a/packages/grafana-data/src/utils/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts similarity index 98% rename from packages/grafana-data/src/utils/fieldReducer.ts rename to packages/grafana-data/src/transformations/fieldReducer.ts index 7655a735e3df3..83714374e482c 100644 --- a/packages/grafana-data/src/utils/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -1,8 +1,8 @@ // Libraries import isNumber from 'lodash/isNumber'; -import { NullValueMode, Field } from '../types'; -import { Registry, RegistryItem } from './registry'; +import { NullValueMode, Field } from '../types/index'; +import { Registry, RegistryItem } from '../utils/Registry'; export enum ReducerID { sum = 'sum', diff --git a/packages/grafana-data/src/transformations/index.ts b/packages/grafana-data/src/transformations/index.ts new file mode 100644 index 0000000000000..798dabce10b12 --- /dev/null +++ b/packages/grafana-data/src/transformations/index.ts @@ -0,0 +1,7 @@ +export * from './matchers/ids'; +export * from './transformers/ids'; +export * from './matchers'; +export * from './transformers'; +export * from './fieldReducer'; +export { FilterFieldsByNameTransformerOptions } from './transformers/filterByName'; +export { ReduceTransformerOptions } from './transformers/reduce'; diff --git a/packages/grafana-data/src/utils/matchers/matchers.ts b/packages/grafana-data/src/transformations/matchers.ts similarity index 55% rename from packages/grafana-data/src/utils/matchers/matchers.ts rename to packages/grafana-data/src/transformations/matchers.ts index d5b45ff771a11..62f127f7518be 100644 --- a/packages/grafana-data/src/utils/matchers/matchers.ts +++ b/packages/grafana-data/src/transformations/matchers.ts @@ -1,27 +1,16 @@ -import { Field, DataFrame } from '../../types/dataFrame'; -import { Registry, RegistryItemWithOptions } from '../registry'; - -export type FieldMatcher = (field: Field) => boolean; -export type FrameMatcher = (frame: DataFrame) => boolean; - -export interface FieldMatcherInfo extends RegistryItemWithOptions { - get: (options: TOptions) => FieldMatcher; -} - -export interface FrameMatcherInfo extends RegistryItemWithOptions { - get: (options: TOptions) => FrameMatcher; -} - -export interface MatcherConfig { - id: string; - options?: TOptions; -} - // Load the Buildtin matchers -import { getFieldPredicateMatchers, getFramePredicateMatchers } from './predicates'; -import { getFieldNameMatchers, getFrameNameMatchers } from './nameMatcher'; -import { getFieldTypeMatchers } from './fieldTypeMatcher'; -import { getRefIdMatchers } from './refIdMatcher'; +import { getFieldPredicateMatchers, getFramePredicateMatchers } from './matchers/predicates'; +import { getFieldNameMatchers, getFrameNameMatchers } from './matchers/nameMatcher'; +import { getFieldTypeMatchers } from './matchers/fieldTypeMatcher'; +import { getRefIdMatchers } from './matchers/refIdMatcher'; +import { + FieldMatcherInfo, + MatcherConfig, + FrameMatcherInfo, + FieldMatcher, + FrameMatcher, +} from '../types/transformations'; +import { Registry } from '../utils/Registry'; export const fieldMatchers = new Registry(() => { return [ diff --git a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts similarity index 84% rename from packages/grafana-data/src/utils/matchers/fieldTypeMatcher.test.ts rename to packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts index 99884f3d60c1d..cf8f7006e3395 100644 --- a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.test.ts +++ b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.test.ts @@ -1,7 +1,7 @@ import { FieldType } from '../../types/dataFrame'; -import { fieldMatchers } from './matchers'; +import { fieldMatchers } from '../matchers'; import { FieldMatcherID } from './ids'; -import { toDataFrame } from '../processDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; export const simpleSeriesWithTypes = toDataFrame({ fields: [ diff --git a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.ts b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts similarity index 95% rename from packages/grafana-data/src/utils/matchers/fieldTypeMatcher.ts rename to packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts index 385bcefca44fe..38dc9c5ef0ac5 100644 --- a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/fieldTypeMatcher.ts @@ -1,6 +1,6 @@ import { Field, FieldType } from '../../types/dataFrame'; -import { FieldMatcherInfo } from './matchers'; import { FieldMatcherID } from './ids'; +import { FieldMatcherInfo } from '../../types/transformations'; // General Field matcher const fieldTypeMacher: FieldMatcherInfo = { diff --git a/packages/grafana-data/src/utils/matchers/ids.ts b/packages/grafana-data/src/transformations/matchers/ids.ts similarity index 100% rename from packages/grafana-data/src/utils/matchers/ids.ts rename to packages/grafana-data/src/transformations/matchers/ids.ts diff --git a/packages/grafana-data/src/utils/matchers/matchers.test.ts b/packages/grafana-data/src/transformations/matchers/matchers.test.ts similarity index 85% rename from packages/grafana-data/src/utils/matchers/matchers.test.ts rename to packages/grafana-data/src/transformations/matchers/matchers.test.ts index 0faeabb14aebb..81ea76b2b757a 100644 --- a/packages/grafana-data/src/utils/matchers/matchers.test.ts +++ b/packages/grafana-data/src/transformations/matchers/matchers.test.ts @@ -1,4 +1,4 @@ -import { fieldMatchers } from './matchers'; +import { fieldMatchers } from '../matchers'; import { FieldMatcherID } from './ids'; describe('Matchers', () => { diff --git a/packages/grafana-data/src/utils/matchers/nameMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts similarity index 92% rename from packages/grafana-data/src/utils/matchers/nameMatcher.test.ts rename to packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts index 7f2880ff0f631..0f3df71088f87 100644 --- a/packages/grafana-data/src/utils/matchers/nameMatcher.test.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts @@ -1,6 +1,6 @@ -import { getFieldMatcher } from './matchers'; +import { getFieldMatcher } from '../matchers'; import { FieldMatcherID } from './ids'; -import { toDataFrame } from '../processDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; describe('Field Name Matcher', () => { it('Match all with wildcard regex', () => { diff --git a/packages/grafana-data/src/utils/matchers/nameMatcher.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts similarity index 90% rename from packages/grafana-data/src/utils/matchers/nameMatcher.ts rename to packages/grafana-data/src/transformations/matchers/nameMatcher.ts index 626b6a908a8a2..1061c2d804770 100644 --- a/packages/grafana-data/src/utils/matchers/nameMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts @@ -1,7 +1,7 @@ import { Field, DataFrame } from '../../types/dataFrame'; -import { FieldMatcherInfo, FrameMatcherInfo } from './matchers'; import { FieldMatcherID, FrameMatcherID } from './ids'; -import { stringToJsRegex } from '../string'; +import { FieldMatcherInfo, FrameMatcherInfo } from '../../types/transformations'; +import { stringToJsRegex } from '../../text/string'; // General Field matcher const fieldNameMacher: FieldMatcherInfo = { diff --git a/packages/grafana-data/src/utils/matchers/predicates.test.ts b/packages/grafana-data/src/transformations/matchers/predicates.test.ts similarity index 92% rename from packages/grafana-data/src/utils/matchers/predicates.test.ts rename to packages/grafana-data/src/transformations/matchers/predicates.test.ts index 97e95ba3129b7..cb4fd1909c145 100644 --- a/packages/grafana-data/src/utils/matchers/predicates.test.ts +++ b/packages/grafana-data/src/transformations/matchers/predicates.test.ts @@ -1,7 +1,8 @@ import { FieldType } from '../../types/dataFrame'; -import { MatcherConfig, fieldMatchers } from './matchers'; +import { fieldMatchers } from '../matchers'; import { simpleSeriesWithTypes } from './fieldTypeMatcher.test'; import { FieldMatcherID, MatcherID } from './ids'; +import { MatcherConfig } from '../../types/transformations'; const matchesNumberConfig: MatcherConfig = { id: FieldMatcherID.byType, diff --git a/packages/grafana-data/src/utils/matchers/predicates.ts b/packages/grafana-data/src/transformations/matchers/predicates.ts similarity index 97% rename from packages/grafana-data/src/utils/matchers/predicates.ts rename to packages/grafana-data/src/transformations/matchers/predicates.ts index 502cceef31117..e122b84ab3c30 100644 --- a/packages/grafana-data/src/utils/matchers/predicates.ts +++ b/packages/grafana-data/src/transformations/matchers/predicates.ts @@ -1,14 +1,7 @@ import { Field, DataFrame } from '../../types/dataFrame'; import { MatcherID } from './ids'; -import { - FrameMatcherInfo, - FieldMatcherInfo, - MatcherConfig, - getFieldMatcher, - fieldMatchers, - getFrameMatchers, - frameMatchers, -} from './matchers'; +import { getFieldMatcher, fieldMatchers, getFrameMatchers, frameMatchers } from '../matchers'; +import { FieldMatcherInfo, MatcherConfig, FrameMatcherInfo } from '../../types/transformations'; const anyFieldMatcher: FieldMatcherInfo = { id: MatcherID.anyMatch, diff --git a/packages/grafana-data/src/utils/matchers/refIdMatcher.ts b/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts similarity index 89% rename from packages/grafana-data/src/utils/matchers/refIdMatcher.ts rename to packages/grafana-data/src/transformations/matchers/refIdMatcher.ts index 51b0db3af8051..0d525d53dd91c 100644 --- a/packages/grafana-data/src/utils/matchers/refIdMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/refIdMatcher.ts @@ -1,6 +1,6 @@ import { DataFrame } from '../../types/dataFrame'; -import { FrameMatcherInfo } from './matchers'; import { FrameMatcherID } from './ids'; +import { FrameMatcherInfo } from '../../types/transformations'; // General Field matcher const refIdMacher: FrameMatcherInfo = { diff --git a/packages/grafana-data/src/utils/transformers/transformers.test.ts b/packages/grafana-data/src/transformations/transformers.test.ts similarity index 62% rename from packages/grafana-data/src/utils/transformers/transformers.test.ts rename to packages/grafana-data/src/transformations/transformers.test.ts index 81df7478d594c..655587e266790 100644 --- a/packages/grafana-data/src/utils/transformers/transformers.test.ts +++ b/packages/grafana-data/src/transformations/transformers.test.ts @@ -1,13 +1,13 @@ -import { DataTransformerID } from './ids'; -import { dataTransformers } from './transformers'; -import { toDataFrame } from '../processDataFrame'; -import { ReducerID } from '../fieldReducer'; -import { DataFrameView } from '../dataFrameView'; +import { DataTransformerID } from './transformers/ids'; +import { transformersRegistry } from './transformers'; +import { toDataFrame } from '../dataframe/processDataFrame'; +import { ReducerID } from './fieldReducer'; +import { DataFrameView } from '../dataframe/DataFrameView'; describe('Transformers', () => { it('should load all transformeres', () => { for (const name of Object.keys(DataTransformerID)) { - const calc = dataTransformers.get(name); + const calc = transformersRegistry.get(name); expect(calc.id).toBe(name); } }); @@ -20,7 +20,7 @@ describe('Transformers', () => { }); it('should use fluent API', () => { - const results = dataTransformers.reduce([seriesWithValues], { + const results = transformersRegistry.reduce([seriesWithValues], { reducers: [ReducerID.first], }); expect(results.length).toBe(1); diff --git a/packages/grafana-data/src/utils/transformers/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts similarity index 71% rename from packages/grafana-data/src/utils/transformers/transformers.ts rename to packages/grafana-data/src/transformations/transformers.ts index 04cee6e763410..189a76d03abed 100644 --- a/packages/grafana-data/src/utils/transformers/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -1,19 +1,13 @@ -import { DataFrame } from '../../types/dataFrame'; -import { Registry, RegistryItemWithOptions } from '../registry'; - -/** - * Immutable data transformation - */ -export type DataTransformer = (data: DataFrame[]) => DataFrame[]; - -export interface DataTransformerInfo extends RegistryItemWithOptions { - transformer: (options: TOptions) => DataTransformer; -} +import { DataFrame } from '../types/dataFrame'; +import { Registry } from '../utils/Registry'; +// Initalize the Registry -export interface DataTransformerConfig { - id: string; - options: TOptions; -} +import { appendTransformer, AppendOptions } from './transformers/append'; +import { reduceTransformer, ReduceTransformerOptions } from './transformers/reduce'; +import { filterFieldsTransformer, filterFramesTransformer } from './transformers/filter'; +import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } from './transformers/filterByName'; +import { noopTransformer } from './transformers/noop'; +import { DataTransformerInfo, DataTransformerConfig } from '../types/transformations'; /** * Apply configured transformations to the input data @@ -21,7 +15,7 @@ export interface DataTransformerConfig { export function transformDataFrame(options: DataTransformerConfig[], data: DataFrame[]): DataFrame[] { let processed = data; for (const config of options) { - const info = dataTransformers.get(config.id); + const info = transformersRegistry.get(config.id); const transformer = info.transformer(config.options); const after = transformer(processed); @@ -43,14 +37,6 @@ export function transformDataFrame(options: DataTransformerConfig[], data: DataF return processed; } -// Initalize the Registry - -import { appendTransformer, AppendOptions } from './append'; -import { reduceTransformer, ReduceTransformerOptions } from './reduce'; -import { filterFieldsTransformer, filterFramesTransformer } from './filter'; -import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } from './filterByName'; -import { noopTransformer } from './noop'; - /** * Registry of transformation options that can be driven by * stored configuration files. @@ -73,7 +59,7 @@ class TransformerRegistry extends Registry { } } -export const dataTransformers = new TransformerRegistry(() => [ +export const transformersRegistry = new TransformerRegistry(() => [ noopTransformer, filterFieldsTransformer, filterFieldsByNameTransformer, diff --git a/packages/grafana-data/src/utils/transformers/__snapshots__/reduce.test.ts.snap b/packages/grafana-data/src/transformations/transformers/__snapshots__/reduce.test.ts.snap similarity index 100% rename from packages/grafana-data/src/utils/transformers/__snapshots__/reduce.test.ts.snap rename to packages/grafana-data/src/transformations/transformers/__snapshots__/reduce.test.ts.snap diff --git a/packages/grafana-data/src/utils/transformers/append.test.ts b/packages/grafana-data/src/transformations/transformers/append.test.ts similarity index 79% rename from packages/grafana-data/src/utils/transformers/append.test.ts rename to packages/grafana-data/src/transformations/transformers/append.test.ts index 4881c714f28e3..6d92627dccddc 100644 --- a/packages/grafana-data/src/utils/transformers/append.test.ts +++ b/packages/grafana-data/src/transformations/transformers/append.test.ts @@ -1,6 +1,7 @@ -import { transformDataFrame, dataTransformers } from './transformers'; import { DataTransformerID } from './ids'; -import { toDataFrame } from '../processDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { transformDataFrame } from '../transformers'; +import { transformersRegistry } from '../transformers'; const seriesAB = toDataFrame({ columns: [{ text: 'A' }, { text: 'B' }], @@ -24,7 +25,7 @@ describe('Append Transformer', () => { id: DataTransformerID.append, options: {}, }; - const x = dataTransformers.get(DataTransformerID.append); + const x = transformersRegistry.get(DataTransformerID.append); expect(x.id).toBe(cfg.id); const processed = transformDataFrame([cfg], [seriesAB, seriesBC])[0]; diff --git a/packages/grafana-data/src/utils/transformers/append.ts b/packages/grafana-data/src/transformations/transformers/append.ts similarity index 91% rename from packages/grafana-data/src/utils/transformers/append.ts rename to packages/grafana-data/src/transformations/transformers/append.ts index b982cdb147837..952f006bbe2b3 100644 --- a/packages/grafana-data/src/utils/transformers/append.ts +++ b/packages/grafana-data/src/transformations/transformers/append.ts @@ -1,7 +1,7 @@ -import { DataTransformerInfo } from './transformers'; import { DataFrame } from '../../types/dataFrame'; import { DataTransformerID } from './ids'; -import { MutableDataFrame } from '../dataFrameHelper'; +import { MutableDataFrame } from '../../dataframe/MutableDataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; export interface AppendOptions {} diff --git a/packages/grafana-data/src/utils/transformers/filter.test.ts b/packages/grafana-data/src/transformations/transformers/filter.test.ts similarity index 87% rename from packages/grafana-data/src/utils/transformers/filter.test.ts rename to packages/grafana-data/src/transformations/transformers/filter.test.ts index 28b3c11f6f700..af8846674bc2d 100644 --- a/packages/grafana-data/src/utils/transformers/filter.test.ts +++ b/packages/grafana-data/src/transformations/transformers/filter.test.ts @@ -1,8 +1,8 @@ import { FieldType } from '../../types/dataFrame'; -import { FieldMatcherID } from '../matchers/ids'; -import { transformDataFrame } from './transformers'; import { DataTransformerID } from './ids'; -import { toDataFrame } from '../processDataFrame'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldMatcherID } from '../matchers/ids'; +import { transformDataFrame } from '../transformers'; export const simpleSeriesWithTypes = toDataFrame({ fields: [ diff --git a/packages/grafana-data/src/utils/transformers/filter.ts b/packages/grafana-data/src/transformations/transformers/filter.ts similarity index 95% rename from packages/grafana-data/src/utils/transformers/filter.ts rename to packages/grafana-data/src/transformations/transformers/filter.ts index 7837c03d233fa..fcce7766c872a 100644 --- a/packages/grafana-data/src/utils/transformers/filter.ts +++ b/packages/grafana-data/src/transformations/transformers/filter.ts @@ -1,9 +1,9 @@ -import { DataTransformerInfo } from './transformers'; import { noopTransformer } from './noop'; import { DataFrame, Field } from '../../types/dataFrame'; -import { FieldMatcherID } from '../matchers/ids'; import { DataTransformerID } from './ids'; -import { MatcherConfig, getFieldMatcher, getFrameMatchers } from '../matchers/matchers'; +import { DataTransformerInfo, MatcherConfig } from '../../types/transformations'; +import { FieldMatcherID } from '../matchers/ids'; +import { getFieldMatcher, getFrameMatchers } from '../matchers'; export interface FilterOptions { include?: MatcherConfig; diff --git a/packages/grafana-data/src/utils/transformers/filterByName.test.ts b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts similarity index 91% rename from packages/grafana-data/src/utils/transformers/filterByName.test.ts rename to packages/grafana-data/src/transformations/transformers/filterByName.test.ts index 7552506541287..ac764da977de6 100644 --- a/packages/grafana-data/src/utils/transformers/filterByName.test.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByName.test.ts @@ -1,6 +1,7 @@ -import { toDataFrame, transformDataFrame } from '../index'; -import { FieldType } from '../../index'; import { DataTransformerID } from './ids'; +import { transformDataFrame } from '../transformers'; +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; export const seriesWithNamesToMatch = toDataFrame({ fields: [ diff --git a/packages/grafana-data/src/utils/transformers/filterByName.ts b/packages/grafana-data/src/transformations/transformers/filterByName.ts similarity index 94% rename from packages/grafana-data/src/utils/transformers/filterByName.ts rename to packages/grafana-data/src/transformations/transformers/filterByName.ts index 1d72116dc72b4..7ece827065c46 100644 --- a/packages/grafana-data/src/utils/transformers/filterByName.ts +++ b/packages/grafana-data/src/transformations/transformers/filterByName.ts @@ -1,7 +1,7 @@ -import { DataTransformerInfo } from './transformers'; -import { FieldMatcherID } from '../matchers/ids'; import { DataTransformerID } from './ids'; import { filterFieldsTransformer, FilterOptions } from './filter'; +import { DataTransformerInfo } from '../../types/transformations'; +import { FieldMatcherID } from '../matchers/ids'; export interface FilterFieldsByNameTransformerOptions { include?: string; diff --git a/packages/grafana-data/src/utils/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts similarity index 100% rename from packages/grafana-data/src/utils/transformers/ids.ts rename to packages/grafana-data/src/transformations/transformers/ids.ts diff --git a/packages/grafana-data/src/utils/transformers/noop.ts b/packages/grafana-data/src/transformations/transformers/noop.ts similarity index 90% rename from packages/grafana-data/src/utils/transformers/noop.ts rename to packages/grafana-data/src/transformations/transformers/noop.ts index 96bded6b89a34..7feb0b853d6f5 100644 --- a/packages/grafana-data/src/utils/transformers/noop.ts +++ b/packages/grafana-data/src/transformations/transformers/noop.ts @@ -1,6 +1,6 @@ -import { DataTransformerInfo } from './transformers'; import { DataTransformerID } from './ids'; import { DataFrame } from '../../types/dataFrame'; +import { DataTransformerInfo } from '../../types/transformations'; export interface NoopTransformerOptions { include?: string; diff --git a/packages/grafana-data/src/utils/transformers/reduce.test.ts b/packages/grafana-data/src/transformations/transformers/reduce.test.ts similarity index 83% rename from packages/grafana-data/src/utils/transformers/reduce.test.ts rename to packages/grafana-data/src/transformations/transformers/reduce.test.ts index ed0ce16b6f71e..244f87dff8c7a 100644 --- a/packages/grafana-data/src/utils/transformers/reduce.test.ts +++ b/packages/grafana-data/src/transformations/transformers/reduce.test.ts @@ -1,7 +1,7 @@ -import { transformDataFrame } from './transformers'; import { ReducerID } from '../fieldReducer'; import { DataTransformerID } from './ids'; -import { toDataFrame, toDataFrameDTO } from '../processDataFrame'; +import { toDataFrame, toDataFrameDTO } from '../../dataframe/processDataFrame'; +import { transformDataFrame } from '../transformers'; const seriesWithValues = toDataFrame({ fields: [ diff --git a/packages/grafana-data/src/utils/transformers/reduce.ts b/packages/grafana-data/src/transformations/transformers/reduce.ts similarity index 89% rename from packages/grafana-data/src/utils/transformers/reduce.ts rename to packages/grafana-data/src/transformations/transformers/reduce.ts index a30a9c0ef1233..ca61bc93c2ea9 100644 --- a/packages/grafana-data/src/utils/transformers/reduce.ts +++ b/packages/grafana-data/src/transformations/transformers/reduce.ts @@ -1,12 +1,12 @@ -import { DataTransformerInfo } from './transformers'; -import { DataFrame, FieldType, Field } from '../../types/dataFrame'; -import { MatcherConfig, getFieldMatcher } from '../matchers/matchers'; -import { alwaysFieldMatcher } from '../matchers/predicates'; import { DataTransformerID } from './ids'; +import { MatcherConfig, DataTransformerInfo } from '../../types/transformations'; import { ReducerID, fieldReducers, reduceField } from '../fieldReducer'; +import { alwaysFieldMatcher } from '../matchers/predicates'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { ArrayVector } from '../../vector/ArrayVector'; import { KeyValue } from '../../types/data'; -import { ArrayVector } from '../vector'; -import { guessFieldTypeForField } from '../processDataFrame'; +import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; +import { getFieldMatcher } from '../matchers'; export interface ReduceTransformerOptions { reducers: ReducerID[]; diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index add607d0a53b7..03d965160ae8f 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -1,9 +1,10 @@ import { Threshold } from './threshold'; import { ValueMapping } from './valueMapping'; import { QueryResultBase, Labels, NullValueMode } from './data'; -import { FieldCalcs } from '../utils/index'; import { DisplayProcessor } from './displayValue'; import { DataLink } from './dataLink'; +import { Vector } from './vector'; +import { FieldCalcs } from '../transformations/fieldReducer'; export enum FieldType { time = 'time', // or date @@ -44,25 +45,6 @@ export interface FieldConfig { noValue?: string; } -export interface Vector { - length: number; - - /** - * Access the value by index (Like an array) - */ - get(index: number): T; - - /** - * Get the resutls as an array. - */ - toArray(): T[]; - - /** - * Return the values as a simple array for json serialization - */ - toJSON(): any; // same results as toArray() -} - export interface Field> { name: string; // The column name type: FieldType; diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 08340b2d01047..fd8d362074ab9 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -11,3 +11,4 @@ export * from './valueMapping'; export * from './displayValue'; export * from './graph'; export * from './ScopedVars'; +export * from './transformations'; diff --git a/packages/grafana-data/src/types/time.ts b/packages/grafana-data/src/types/time.ts index ae8137277f386..0eb2c51929d1f 100644 --- a/packages/grafana-data/src/types/time.ts +++ b/packages/grafana-data/src/types/time.ts @@ -1,4 +1,4 @@ -import { DateTime } from '../utils/moment_wrapper'; +import { DateTime } from '../datetime/moment_wrapper'; export interface RawTimeRange { from: DateTime | string; diff --git a/packages/grafana-data/src/types/transformations.ts b/packages/grafana-data/src/types/transformations.ts new file mode 100644 index 0000000000000..6fab55410f537 --- /dev/null +++ b/packages/grafana-data/src/types/transformations.ts @@ -0,0 +1,32 @@ +import { DataFrame, Field } from './dataFrame'; +import { RegistryItemWithOptions } from '../utils/Registry'; + +/** + * Immutable data transformation + */ +export type DataTransformer = (data: DataFrame[]) => DataFrame[]; + +export interface DataTransformerInfo extends RegistryItemWithOptions { + transformer: (options: TOptions) => DataTransformer; +} + +export interface DataTransformerConfig { + id: string; + options: TOptions; +} + +export type FieldMatcher = (field: Field) => boolean; +export type FrameMatcher = (frame: DataFrame) => boolean; + +export interface FieldMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FieldMatcher; +} + +export interface FrameMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FrameMatcher; +} + +export interface MatcherConfig { + id: string; + options?: TOptions; +} diff --git a/packages/grafana-data/src/types/vector.ts b/packages/grafana-data/src/types/vector.ts new file mode 100644 index 0000000000000..7e667d725a9f2 --- /dev/null +++ b/packages/grafana-data/src/types/vector.ts @@ -0,0 +1,40 @@ +export interface Vector { + length: number; + + /** + * Access the value by index (Like an array) + */ + get(index: number): T; + + /** + * Get the resutls as an array. + */ + toArray(): T[]; + + /** + * Return the values as a simple array for json serialization + */ + toJSON(): any; // same results as toArray() +} + +/** + * Apache arrow vectors are Read/Write + */ +export interface ReadWriteVector extends Vector { + set: (index: number, value: T) => void; +} + +/** + * Vector with standard manipulation functions + */ +export interface MutableVector extends ReadWriteVector { + /** + * Adds the value to the vector + */ + add: (value: T) => void; + + /** + * modifies the vector so it is now the oposite order + */ + reverse: () => void; +} diff --git a/packages/grafana-data/src/utils/registry.ts b/packages/grafana-data/src/utils/Registry.ts similarity index 100% rename from packages/grafana-data/src/utils/registry.ts rename to packages/grafana-data/src/utils/Registry.ts diff --git a/packages/grafana-data/src/utils/csv.test.ts b/packages/grafana-data/src/utils/csv.test.ts index 23adebcf467b7..c7ed640bfa9fa 100644 --- a/packages/grafana-data/src/utils/csv.test.ts +++ b/packages/grafana-data/src/utils/csv.test.ts @@ -1,9 +1,9 @@ import { readCSV, toCSV, CSVHeaderStyle } from './csv'; -import { getDataFrameRow } from './processDataFrame'; +import { getDataFrameRow } from '../dataframe/processDataFrame'; // Test with local CSV files import fs from 'fs'; -import { toDataFrameDTO } from './processDataFrame'; +import { toDataFrameDTO } from '../dataframe/processDataFrame'; describe('read csv', () => { it('should get X and y', () => { diff --git a/packages/grafana-data/src/utils/csv.ts b/packages/grafana-data/src/utils/csv.ts index fd3664676e226..3d78716310182 100644 --- a/packages/grafana-data/src/utils/csv.ts +++ b/packages/grafana-data/src/utils/csv.ts @@ -5,8 +5,8 @@ import isNumber from 'lodash/isNumber'; // Types import { DataFrame, Field, FieldType, FieldConfig } from '../types'; -import { guessFieldTypeFromValue } from './processDataFrame'; -import { MutableDataFrame } from './dataFrameHelper'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; export enum CSVHeaderStyle { full, diff --git a/packages/grafana-data/src/utils/fieldParser.ts b/packages/grafana-data/src/utils/fieldParser.ts new file mode 100644 index 0000000000000..939f4e401f299 --- /dev/null +++ b/packages/grafana-data/src/utils/fieldParser.ts @@ -0,0 +1,28 @@ +import { Field, FieldType } from '../types/dataFrame'; +import { guessFieldTypeFromValue } from '../dataframe/processDataFrame'; + +export function makeFieldParser(value: any, field: Field): (value: string) => any { + if (!field.type) { + if (field.name === 'time' || field.name === 'Time') { + field.type = FieldType.time; + } else { + field.type = guessFieldTypeFromValue(value); + } + } + + if (field.type === FieldType.number) { + return (value: string) => { + return parseFloat(value); + }; + } + + // Will convert anything that starts with "T" to true + if (field.type === FieldType.boolean) { + return (value: string) => { + return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0'); + }; + } + + // Just pass the string back + return (value: string) => value; +} diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index 2479adafe5892..5f3d7d23ef4b1 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -1,29 +1,10 @@ -export * from './string'; -export * from './registry'; -export * from './markdown'; -export * from './processDataFrame'; +export * from './Registry'; export * from './deprecationWarning'; export * from './csv'; -export * from './fieldReducer'; export * from './logs'; export * from './labels'; export * from './labels'; export * from './object'; -export * from './moment_wrapper'; export * from './thresholds'; -export * from './text'; -export * from './dataFrameHelper'; -export * from './dataFrameView'; -export * from './vector'; export { getMappedValue } from './valueMappings'; - -// Names are too general to export globally -import * as dateMath from './datemath'; -import * as rangeUtil from './rangeutil'; -export { dateMath, rangeUtil }; - -export * from './matchers/ids'; -export * from './matchers/matchers'; -export * from './transformers/ids'; -export * from './transformers/transformers'; diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts index 196a6a28dd734..b14542d57feac 100644 --- a/packages/grafana-data/src/utils/logs.ts +++ b/packages/grafana-data/src/utils/logs.ts @@ -2,7 +2,7 @@ import { countBy, chain, map, escapeRegExp } from 'lodash'; import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser } from '../types/logs'; import { DataFrame, FieldType } from '../types/index'; -import { ArrayVector } from './vector'; +import { ArrayVector } from '../vector/ArrayVector'; const LOGFMT_REGEXP = /(?:^|\s)(\w+)=("[^"]*"|\S+)/; diff --git a/packages/grafana-data/src/utils/vector.ts b/packages/grafana-data/src/utils/vector.ts deleted file mode 100644 index 0a625b01b4947..0000000000000 --- a/packages/grafana-data/src/utils/vector.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { Vector } from '../types/dataFrame'; - -export function vectorToArray(v: Vector): T[] { - const arr: T[] = []; - for (let i = 0; i < v.length; i++) { - arr[i] = v.get(i); - } - return arr; -} - -/** - * Apache arrow vectors are Read/Write - */ -export interface ReadWriteVector extends Vector { - set: (index: number, value: T) => void; -} - -/** - * Vector with standard manipulation functions - */ -export interface MutableVector extends ReadWriteVector { - /** - * Adds the value to the vector - */ - add: (value: T) => void; - - /** - * modifies the vector so it is now the oposite order - */ - reverse: () => void; -} - -export class ArrayVector implements MutableVector { - buffer: T[]; - - constructor(buffer?: T[]) { - this.buffer = buffer ? buffer : []; - } - - get length() { - return this.buffer.length; - } - - add(value: T) { - this.buffer.push(value); - } - - get(index: number): T { - return this.buffer[index]; - } - - set(index: number, value: T) { - this.buffer[index] = value; - } - - reverse() { - this.buffer.reverse(); - } - - toArray(): T[] { - return this.buffer; - } - - toJSON(): T[] { - return this.buffer; - } -} - -export class ConstantVector implements Vector { - constructor(private value: T, private len: number) {} - - get length() { - return this.len; - } - - get(index: number): T { - return this.value; - } - - toArray(): T[] { - const arr = new Array(this.length); - return arr.fill(this.value); - } - - toJSON(): T[] { - return this.toArray(); - } -} - -export class ScaledVector implements Vector { - constructor(private source: Vector, private scale: number) {} - - get length(): number { - return this.source.length; - } - - get(index: number): number { - return this.source.get(index) * this.scale; - } - - toArray(): number[] { - return vectorToArray(this); - } - - toJSON(): number[] { - return vectorToArray(this); - } -} - -/** - * Values are returned in the order defined by the input parameter - */ -export class SortedVector implements Vector { - constructor(private source: Vector, private order: number[]) {} - - get length(): number { - return this.source.length; - } - - get(index: number): T { - return this.source.get(this.order[index]); - } - - toArray(): T[] { - return vectorToArray(this); - } - - toJSON(): T[] { - return vectorToArray(this); - } -} - -interface CircularOptions { - buffer?: T[]; - append?: 'head' | 'tail'; - capacity?: number; -} - -/** - * Circular vector uses a single buffer to capture a stream of values - * overwriting the oldest value on add. - * - * This supports addting to the 'head' or 'tail' and will grow the buffer - * to match a configured capacity. - */ -export class CircularVector implements MutableVector { - private buffer: T[]; - private index: number; - private capacity: number; - private tail: boolean; - - constructor(options: CircularOptions) { - this.buffer = options.buffer || []; - this.capacity = this.buffer.length; - this.tail = 'head' !== options.append; - this.index = 0; - - this.add = this.getAddFunction(); - if (options.capacity) { - this.setCapacity(options.capacity); - } - } - - /** - * This gets the appropriate add function depending on the buffer state: - * * head vs tail - * * growing buffer vs overwriting values - */ - private getAddFunction() { - // When we are not at capacity, it should actually modify the buffer - if (this.capacity > this.buffer.length) { - if (this.tail) { - return (value: T) => { - this.buffer.push(value); - if (this.buffer.length >= this.capacity) { - this.add = this.getAddFunction(); - } - }; - } else { - return (value: T) => { - this.buffer.unshift(value); - if (this.buffer.length >= this.capacity) { - this.add = this.getAddFunction(); - } - }; - } - } - - if (this.tail) { - return (value: T) => { - this.buffer[this.index] = value; - this.index = (this.index + 1) % this.buffer.length; - }; - } - - // Append values to the head - return (value: T) => { - let idx = this.index - 1; - if (idx < 0) { - idx = this.buffer.length - 1; - } - this.buffer[idx] = value; - this.index = idx; - }; - } - - setCapacity(v: number) { - if (this.capacity === v) { - return; - } - // Make a copy so it is in order and new additions can be at the head or tail - const copy = this.toArray(); - if (v > this.length) { - this.buffer = copy; - } else if (v < this.capacity) { - // Shrink the buffer - const delta = this.length - v; - if (this.tail) { - this.buffer = copy.slice(delta, copy.length); // Keep last items - } else { - this.buffer = copy.slice(0, copy.length - delta); // Keep first items - } - } - this.capacity = v; - this.index = 0; - this.add = this.getAddFunction(); - } - - setAppendMode(mode: 'head' | 'tail') { - const tail = 'head' !== mode; - if (tail !== this.tail) { - this.buffer = this.toArray().reverse(); - this.index = 0; - this.tail = tail; - this.add = this.getAddFunction(); - } - } - - reverse() { - this.buffer.reverse(); - } - - /** - * Add the value to the buffer - */ - add: (value: T) => void; - - get(index: number) { - return this.buffer[(index + this.index) % this.buffer.length]; - } - - set(index: number, value: T) { - this.buffer[(index + this.index) % this.buffer.length] = value; - } - - get length() { - return this.buffer.length; - } - - toArray(): T[] { - return vectorToArray(this); - } - - toJSON(): T[] { - return vectorToArray(this); - } -} - -interface AppendedVectorInfo { - start: number; - end: number; - values: Vector; -} - -/** - * This may be more trouble than it is worth. This trades some computation time for - * RAM -- rather than allocate a new array the size of all previous arrays, this just - * points the correct index to their original array values - */ -export class AppendedVectors implements Vector { - length = 0; - source: Array> = new Array>(); - - constructor(startAt = 0) { - this.length = startAt; - } - - /** - * Make the vector look like it is this long - */ - setLength(length: number) { - if (length > this.length) { - // make the vector longer (filling with undefined) - this.length = length; - } else if (length < this.length) { - // make the array shorter - const sources: Array> = new Array>(); - for (const src of this.source) { - sources.push(src); - if (src.end > length) { - src.end = length; - break; - } - } - this.source = sources; - this.length = length; - } - } - - append(v: Vector): AppendedVectorInfo { - const info = { - start: this.length, - end: this.length + v.length, - values: v, - }; - this.length = info.end; - this.source.push(info); - return info; - } - - get(index: number): T { - for (let i = 0; i < this.source.length; i++) { - const src = this.source[i]; - if (index >= src.start && index < src.end) { - return src.values.get(index - src.start); - } - } - return (undefined as unknown) as T; - } - - toArray(): T[] { - return vectorToArray(this); - } - - toJSON(): T[] { - return vectorToArray(this); - } -} diff --git a/packages/grafana-data/src/vector/AppendedVectors.test.ts b/packages/grafana-data/src/vector/AppendedVectors.test.ts new file mode 100644 index 0000000000000..9d39b47ce091e --- /dev/null +++ b/packages/grafana-data/src/vector/AppendedVectors.test.ts @@ -0,0 +1,23 @@ +import { ArrayVector } from './ArrayVector'; +import { AppendedVectors } from './AppendedVectors'; + +describe('Check Appending Vector', () => { + it('should transparently join them', () => { + const appended = new AppendedVectors(); + appended.append(new ArrayVector([1, 2, 3])); + appended.append(new ArrayVector([4, 5, 6])); + appended.append(new ArrayVector([7, 8, 9])); + expect(appended.length).toEqual(9); + + appended.setLength(5); + expect(appended.length).toEqual(5); + appended.append(new ArrayVector(['a', 'b', 'c'])); + expect(appended.length).toEqual(8); + expect(appended.toArray()).toEqual([1, 2, 3, 4, 5, 'a', 'b', 'c']); + + appended.setLength(2); + appended.setLength(6); + appended.append(new ArrayVector(['x', 'y', 'z'])); + expect(appended.toArray()).toEqual([1, 2, undefined, undefined, undefined, undefined, 'x', 'y', 'z']); + }); +}); diff --git a/packages/grafana-data/src/vector/AppendedVectors.ts b/packages/grafana-data/src/vector/AppendedVectors.ts new file mode 100644 index 0000000000000..7cfc0d42c4706 --- /dev/null +++ b/packages/grafana-data/src/vector/AppendedVectors.ts @@ -0,0 +1,73 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +interface AppendedVectorInfo { + start: number; + end: number; + values: Vector; +} + +/** + * This may be more trouble than it is worth. This trades some computation time for + * RAM -- rather than allocate a new array the size of all previous arrays, this just + * points the correct index to their original array values + */ +export class AppendedVectors implements Vector { + length = 0; + source: Array> = new Array>(); + + constructor(startAt = 0) { + this.length = startAt; + } + + /** + * Make the vector look like it is this long + */ + setLength(length: number) { + if (length > this.length) { + // make the vector longer (filling with undefined) + this.length = length; + } else if (length < this.length) { + // make the array shorter + const sources: Array> = new Array>(); + for (const src of this.source) { + sources.push(src); + if (src.end > length) { + src.end = length; + break; + } + } + this.source = sources; + this.length = length; + } + } + + append(v: Vector): AppendedVectorInfo { + const info = { + start: this.length, + end: this.length + v.length, + values: v, + }; + this.length = info.end; + this.source.push(info); + return info; + } + + get(index: number): T { + for (let i = 0; i < this.source.length; i++) { + const src = this.source[i]; + if (index >= src.start && index < src.end) { + return src.values.get(index - src.start); + } + } + return (undefined as unknown) as T; + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/ArrayVector.ts b/packages/grafana-data/src/vector/ArrayVector.ts new file mode 100644 index 0000000000000..63c614f1cc4d9 --- /dev/null +++ b/packages/grafana-data/src/vector/ArrayVector.ts @@ -0,0 +1,37 @@ +import { MutableVector } from '../types/vector'; + +export class ArrayVector implements MutableVector { + buffer: T[]; + + constructor(buffer?: T[]) { + this.buffer = buffer ? buffer : []; + } + + get length() { + return this.buffer.length; + } + + add(value: T) { + this.buffer.push(value); + } + + get(index: number): T { + return this.buffer[index]; + } + + set(index: number, value: T) { + this.buffer[index] = value; + } + + reverse() { + this.buffer.reverse(); + } + + toArray(): T[] { + return this.buffer; + } + + toJSON(): T[] { + return this.buffer; + } +} diff --git a/packages/grafana-data/src/utils/vector.test.ts b/packages/grafana-data/src/vector/CircularVector.test.ts similarity index 65% rename from packages/grafana-data/src/utils/vector.test.ts rename to packages/grafana-data/src/vector/CircularVector.test.ts index 1806020ed59fa..abffd15846ba9 100644 --- a/packages/grafana-data/src/utils/vector.test.ts +++ b/packages/grafana-data/src/vector/CircularVector.test.ts @@ -1,31 +1,4 @@ -import { ConstantVector, ScaledVector, ArrayVector, CircularVector, AppendedVectors } from './vector'; - -describe('Check Proxy Vector', () => { - it('should support constant values', () => { - const value = 3.5; - const v = new ConstantVector(value, 7); - expect(v.length).toEqual(7); - - expect(v.get(0)).toEqual(value); - expect(v.get(1)).toEqual(value); - - // Now check all of them - for (let i = 0; i < 10; i++) { - expect(v.get(i)).toEqual(value); - } - }); - - it('should support multiply operations', () => { - const source = new ArrayVector([1, 2, 3, 4]); - const scale = 2.456; - const v = new ScaledVector(source, scale); - expect(v.length).toEqual(source.length); - // expect(v.push(10)).toEqual(source.length); // not implemented - for (let i = 0; i < 10; i++) { - expect(v.get(i)).toEqual(source.get(i) * scale); - } - }); -}); +import { CircularVector } from './CircularVector'; describe('Check Circular Vector', () => { it('should append values', () => { @@ -156,24 +129,3 @@ describe('Check Circular Vector', () => { expect(v.toArray()).toEqual([3, 4, 5]); }); }); - -describe('Check Appending Vector', () => { - it('should transparently join them', () => { - const appended = new AppendedVectors(); - appended.append(new ArrayVector([1, 2, 3])); - appended.append(new ArrayVector([4, 5, 6])); - appended.append(new ArrayVector([7, 8, 9])); - expect(appended.length).toEqual(9); - - appended.setLength(5); - expect(appended.length).toEqual(5); - appended.append(new ArrayVector(['a', 'b', 'c'])); - expect(appended.length).toEqual(8); - expect(appended.toArray()).toEqual([1, 2, 3, 4, 5, 'a', 'b', 'c']); - - appended.setLength(2); - appended.setLength(6); - appended.append(new ArrayVector(['x', 'y', 'z'])); - expect(appended.toArray()).toEqual([1, 2, undefined, undefined, undefined, undefined, 'x', 'y', 'z']); - }); -}); diff --git a/packages/grafana-data/src/vector/CircularVector.ts b/packages/grafana-data/src/vector/CircularVector.ts new file mode 100644 index 0000000000000..f0eeb6579f036 --- /dev/null +++ b/packages/grafana-data/src/vector/CircularVector.ts @@ -0,0 +1,138 @@ +import { MutableVector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +interface CircularOptions { + buffer?: T[]; + append?: 'head' | 'tail'; + capacity?: number; +} + +/** + * Circular vector uses a single buffer to capture a stream of values + * overwriting the oldest value on add. + * + * This supports addting to the 'head' or 'tail' and will grow the buffer + * to match a configured capacity. + */ +export class CircularVector implements MutableVector { + private buffer: T[]; + private index: number; + private capacity: number; + private tail: boolean; + + constructor(options: CircularOptions) { + this.buffer = options.buffer || []; + this.capacity = this.buffer.length; + this.tail = 'head' !== options.append; + this.index = 0; + + this.add = this.getAddFunction(); + if (options.capacity) { + this.setCapacity(options.capacity); + } + } + + /** + * This gets the appropriate add function depending on the buffer state: + * * head vs tail + * * growing buffer vs overwriting values + */ + private getAddFunction() { + // When we are not at capacity, it should actually modify the buffer + if (this.capacity > this.buffer.length) { + if (this.tail) { + return (value: T) => { + this.buffer.push(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } else { + return (value: T) => { + this.buffer.unshift(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } + } + + if (this.tail) { + return (value: T) => { + this.buffer[this.index] = value; + this.index = (this.index + 1) % this.buffer.length; + }; + } + + // Append values to the head + return (value: T) => { + let idx = this.index - 1; + if (idx < 0) { + idx = this.buffer.length - 1; + } + this.buffer[idx] = value; + this.index = idx; + }; + } + + setCapacity(v: number) { + if (this.capacity === v) { + return; + } + // Make a copy so it is in order and new additions can be at the head or tail + const copy = this.toArray(); + if (v > this.length) { + this.buffer = copy; + } else if (v < this.capacity) { + // Shrink the buffer + const delta = this.length - v; + if (this.tail) { + this.buffer = copy.slice(delta, copy.length); // Keep last items + } else { + this.buffer = copy.slice(0, copy.length - delta); // Keep first items + } + } + this.capacity = v; + this.index = 0; + this.add = this.getAddFunction(); + } + + setAppendMode(mode: 'head' | 'tail') { + const tail = 'head' !== mode; + if (tail !== this.tail) { + this.buffer = this.toArray().reverse(); + this.index = 0; + this.tail = tail; + this.add = this.getAddFunction(); + } + } + + reverse() { + this.buffer.reverse(); + } + + /** + * Add the value to the buffer + */ + add: (value: T) => void; + + get(index: number) { + return this.buffer[(index + this.index) % this.buffer.length]; + } + + set(index: number, value: T) { + this.buffer[(index + this.index) % this.buffer.length] = value; + } + + get length() { + return this.buffer.length; + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/ConstantVector.test.ts b/packages/grafana-data/src/vector/ConstantVector.test.ts new file mode 100644 index 0000000000000..b1b942209ee3b --- /dev/null +++ b/packages/grafana-data/src/vector/ConstantVector.test.ts @@ -0,0 +1,17 @@ +import { ConstantVector } from './ConstantVector'; + +describe('ConstantVector', () => { + it('should support constant values', () => { + const value = 3.5; + const v = new ConstantVector(value, 7); + expect(v.length).toEqual(7); + + expect(v.get(0)).toEqual(value); + expect(v.get(1)).toEqual(value); + + // Now check all of them + for (let i = 0; i < 10; i++) { + expect(v.get(i)).toEqual(value); + } + }); +}); diff --git a/packages/grafana-data/src/vector/ConstantVector.ts b/packages/grafana-data/src/vector/ConstantVector.ts new file mode 100644 index 0000000000000..07b3beddc19cb --- /dev/null +++ b/packages/grafana-data/src/vector/ConstantVector.ts @@ -0,0 +1,22 @@ +import { Vector } from '../types/vector'; + +export class ConstantVector implements Vector { + constructor(private value: T, private len: number) {} + + get length() { + return this.len; + } + + get(index: number): T { + return this.value; + } + + toArray(): T[] { + const arr = new Array(this.length); + return arr.fill(this.value); + } + + toJSON(): T[] { + return this.toArray(); + } +} diff --git a/packages/grafana-data/src/vector/ScaledVector.test.ts b/packages/grafana-data/src/vector/ScaledVector.test.ts new file mode 100644 index 0000000000000..b395113559485 --- /dev/null +++ b/packages/grafana-data/src/vector/ScaledVector.test.ts @@ -0,0 +1,15 @@ +import { ArrayVector } from './ArrayVector'; +import { ScaledVector } from './ScaledVector'; + +describe('ScaledVector', () => { + it('should support multiply operations', () => { + const source = new ArrayVector([1, 2, 3, 4]); + const scale = 2.456; + const v = new ScaledVector(source, scale); + expect(v.length).toEqual(source.length); + // expect(v.push(10)).toEqual(source.length); // not implemented + for (let i = 0; i < 10; i++) { + expect(v.get(i)).toEqual(source.get(i) * scale); + } + }); +}); diff --git a/packages/grafana-data/src/vector/ScaledVector.ts b/packages/grafana-data/src/vector/ScaledVector.ts new file mode 100644 index 0000000000000..0656291d7d07c --- /dev/null +++ b/packages/grafana-data/src/vector/ScaledVector.ts @@ -0,0 +1,22 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +export class ScaledVector implements Vector { + constructor(private source: Vector, private scale: number) {} + + get length(): number { + return this.source.length; + } + + get(index: number): number { + return this.source.get(index) * this.scale; + } + + toArray(): number[] { + return vectorToArray(this); + } + + toJSON(): number[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/SortedVector.ts b/packages/grafana-data/src/vector/SortedVector.ts new file mode 100644 index 0000000000000..33b97f6778fa1 --- /dev/null +++ b/packages/grafana-data/src/vector/SortedVector.ts @@ -0,0 +1,25 @@ +import { Vector } from '../types/vector'; +import { vectorToArray } from './vectorToArray'; + +/** + * Values are returned in the order defined by the input parameter + */ +export class SortedVector implements Vector { + constructor(private source: Vector, private order: number[]) {} + + get length(): number { + return this.source.length; + } + + get(index: number): T { + return this.source.get(this.order[index]); + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} diff --git a/packages/grafana-data/src/vector/index.ts b/packages/grafana-data/src/vector/index.ts new file mode 100644 index 0000000000000..392cb37961088 --- /dev/null +++ b/packages/grafana-data/src/vector/index.ts @@ -0,0 +1,6 @@ +export * from './AppendedVectors'; +export * from './ArrayVector'; +export * from './CircularVector'; +export * from './ConstantVector'; +export * from './ScaledVector'; +export * from './SortedVector'; diff --git a/packages/grafana-data/src/vector/vectorToArray.ts b/packages/grafana-data/src/vector/vectorToArray.ts new file mode 100644 index 0000000000000..34212651ab835 --- /dev/null +++ b/packages/grafana-data/src/vector/vectorToArray.ts @@ -0,0 +1,9 @@ +import { Vector } from '../types/vector'; + +export function vectorToArray(v: Vector): T[] { + const arr: T[] = []; + for (let i = 0; i < v.length; i++) { + arr[i] = v.get(i); + } + return arr; +} diff --git a/packages/grafana-ui/src/components/Button/AbstractButton.tsx b/packages/grafana-ui/src/components/Button/AbstractButton.tsx index 030d03fcdd2aa..6ce86ac4176d4 100644 --- a/packages/grafana-ui/src/components/Button/AbstractButton.tsx +++ b/packages/grafana-ui/src/components/Button/AbstractButton.tsx @@ -19,7 +19,9 @@ export interface CommonButtonProps { className?: string; } -export interface LinkButtonProps extends CommonButtonProps, AnchorHTMLAttributes {} +export interface LinkButtonProps extends CommonButtonProps, AnchorHTMLAttributes { + disabled?: boolean; +} export interface ButtonProps extends CommonButtonProps, ButtonHTMLAttributes {} interface AbstractButtonProps extends CommonButtonProps, Themeable { diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index 92a55d71ac072..72f1ae342146e 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -1,24 +1,48 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import { SelectableValue } from '@grafana/data'; +import { css } from 'emotion'; import { Tooltip } from '../Tooltip/Tooltip'; import { ButtonSelect } from '../Select/ButtonSelect'; +import memoizeOne from 'memoize-one'; +import { GrafanaTheme } from '../../types'; +import { withTheme } from '../../themes'; export const offOption = { label: 'Off', value: '' }; export const liveOption = { label: 'Live', value: 'LIVE' }; export const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']; export const isLive = (refreshInterval: string): boolean => refreshInterval === liveOption.value; +const getStyles = memoizeOne((theme: GrafanaTheme) => { + return { + selectButton: css` + label: selectButton; + .select-button-value { + color: ${theme.colors.orange}; + } + `, + }; +}); + export interface Props { intervals?: string[]; - onRefresh: () => any; + onRefresh?: () => any; onIntervalChanged: (interval: string) => void; value?: string; - tooltip: string; + tooltip?: string; hasLiveOption?: boolean; + // You can supply your own refresh button element. In that case onRefresh and tooltip are ignored. + refreshButton?: React.ReactNode; + buttonSelectClassName?: string; + theme: GrafanaTheme; } -export class RefreshPicker extends PureComponent { +export class RefreshPickerBase extends PureComponent { + // Make it exported as static properties to be easier to access. The global exports need to be accessed by direct + // import of this source file which won't work if this was installed as package. + static offOption = offOption; + static liveOption = liveOption; + constructor(props: Props) { super(props); } @@ -46,10 +70,11 @@ export class RefreshPicker extends PureComponent { }; render() { - const { onRefresh, intervals, tooltip, value } = this.props; + const { onRefresh, intervals, tooltip, value, refreshButton, buttonSelectClassName, theme } = this.props; const options = this.intervalsToOptions(intervals); const currentValue = value || ''; const selectedValue = options.find(item => item.value === currentValue) || offOption; + const styles = getStyles(theme); const cssClasses = classNames({ 'refresh-picker': true, @@ -60,13 +85,20 @@ export class RefreshPicker extends PureComponent { return (
- - - + {refreshButton ? ( + refreshButton + ) : ( + + + + )} { ); } } + +export const RefreshPicker = withTheme< + Props, + { + offOption: typeof RefreshPickerBase.offOption; + liveOption: typeof RefreshPickerBase.liveOption; + } +>(RefreshPickerBase); diff --git a/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss b/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss index 3dff67217408f..0146147d79a23 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss +++ b/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss @@ -20,10 +20,6 @@ width: 100%; } - .select-button-value { - color: $orange; - } - &--off { .select-button-value { display: none; diff --git a/packages/grafana-ui/src/components/Select/ButtonSelect.tsx b/packages/grafana-ui/src/components/Select/ButtonSelect.tsx index ab35c12a59980..fe4e7ef2a57d9 100644 --- a/packages/grafana-ui/src/components/Select/ButtonSelect.tsx +++ b/packages/grafana-ui/src/components/Select/ButtonSelect.tsx @@ -13,11 +13,12 @@ const ButtonComponent = (buttonProps: ButtonComponentProps) => (props: any) => { const { label, className, iconClass } = buttonProps; return ( - +
); }; diff --git a/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx b/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx index b44c130b9b9af..9b9561816fd7c 100644 --- a/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx +++ b/packages/grafana-ui/src/components/TransformersUI/FilterByNameTransformerEditor.tsx @@ -1,5 +1,5 @@ import React, { useContext } from 'react'; -import { FilterFieldsByNameTransformerOptions, DataTransformerID, dataTransformers, KeyValue } from '@grafana/data'; +import { FilterFieldsByNameTransformerOptions, DataTransformerID, transformersRegistry, KeyValue } from '@grafana/data'; import { TransformerUIProps, TransformerUIRegistyItem } from './types'; import { ThemeContext } from '../../themes/ThemeContext'; import { css, cx } from 'emotion'; @@ -157,7 +157,7 @@ const FilterPill: React.FC = ({ label, selected, onClick }) => export const filterFieldsByNameTransformRegistryItem: TransformerUIRegistyItem = { id: DataTransformerID.filterFieldsByName, component: FilterByNameTransformerEditor, - transformer: dataTransformers.get(DataTransformerID.filterFieldsByName), + transformer: transformersRegistry.get(DataTransformerID.filterFieldsByName), name: 'Filter by name', description: 'UI for filter by name transformation', }; diff --git a/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx b/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx index d12e9f86e469a..484dd10077f3e 100644 --- a/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx +++ b/packages/grafana-ui/src/components/TransformersUI/ReduceTransformerEditor.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { StatsPicker } from '../StatsPicker/StatsPicker'; -import { ReduceTransformerOptions, DataTransformerID, ReducerID } from '@grafana/data'; +import { ReduceTransformerOptions, DataTransformerID, ReducerID, transformersRegistry } from '@grafana/data'; import { TransformerUIRegistyItem, TransformerUIProps } from './types'; -import { dataTransformers } from '@grafana/data'; // TODO: Minimal implementation, needs some <3 export const ReduceTransformerEditor: React.FC> = ({ @@ -29,7 +28,7 @@ export const ReduceTransformerEditor: React.FC = { id: DataTransformerID.reduce, component: ReduceTransformerEditor, - transformer: dataTransformers.get(DataTransformerID.reduce), + transformer: transformersRegistry.get(DataTransformerID.reduce), name: 'Reduce', description: 'UI for reduce transformation', }; diff --git a/packages/grafana-ui/src/themes/ThemeContext.tsx b/packages/grafana-ui/src/themes/ThemeContext.tsx index 11b3d79481aa9..ea9fcb1502ffe 100644 --- a/packages/grafana-ui/src/themes/ThemeContext.tsx +++ b/packages/grafana-ui/src/themes/ThemeContext.tsx @@ -1,4 +1,6 @@ -import React from 'react'; +import React, { useContext } from 'react'; +import hoistNonReactStatics from 'hoist-non-react-statics'; + import { getTheme } from './getTheme'; import { GrafanaThemeType, Themeable } from '../types/theme'; @@ -8,13 +10,18 @@ type Subtract = Omit; // Use Grafana Dark theme by default export const ThemeContext = React.createContext(getTheme(GrafanaThemeType.Dark)); -export const withTheme =

(Component: React.ComponentType

) => { +export const withTheme =

(Component: React.ComponentType

) => { const WithTheme: React.FunctionComponent> = props => { // @ts-ignore return {theme => }; }; WithTheme.displayName = `WithTheme(${Component.displayName})`; - - return WithTheme; + hoistNonReactStatics(WithTheme, Component); + type Hoisted = typeof WithTheme & S; + return WithTheme as Hoisted; }; + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts index f8e597ae4e836..32e1d5db5f4e1 100644 --- a/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts +++ b/packages/grafana-ui/src/themes/_variables.scss.tmpl.ts @@ -193,6 +193,7 @@ $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default; // sidemenu $side-menu-width: 60px; +$navbar-padding: 20px; // dashboard $dashboard-padding: $space-md; diff --git a/packages/grafana-ui/src/themes/index.ts b/packages/grafana-ui/src/themes/index.ts index 6d3e41e867e53..89b8ab2877da5 100644 --- a/packages/grafana-ui/src/themes/index.ts +++ b/packages/grafana-ui/src/themes/index.ts @@ -1,5 +1,5 @@ -import { ThemeContext, withTheme } from './ThemeContext'; +import { ThemeContext, withTheme, useTheme } from './ThemeContext'; import { getTheme, mockTheme } from './getTheme'; import { selectThemeVariant } from './selectThemeVariant'; -export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant }; +export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant, useTheme }; diff --git a/packages/grafana-ui/src/utils/fieldDisplay.ts b/packages/grafana-ui/src/utils/fieldDisplay.ts index 90fdd7c002788..70f82f017df3f 100644 --- a/packages/grafana-ui/src/utils/fieldDisplay.ts +++ b/packages/grafana-ui/src/utils/fieldDisplay.ts @@ -50,13 +50,13 @@ function getTitleTemplate(title: string | undefined, stats: string[], data?: Dat const parts: string[] = []; if (stats.length > 1) { - parts.push('$' + VAR_CALC); + parts.push('${' + VAR_CALC + '}'); } if (data.length > 1) { parts.push('${' + VAR_SERIES_NAME + '}'); } if (fieldCount > 1 || !parts.length) { - parts.push('$' + VAR_FIELD_NAME); + parts.push('${' + VAR_FIELD_NAME + '}'); } return parts.join(' '); } diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile index b26a08a3784d4..bc74a2c6fdf50 100644 --- a/packaging/docker/Dockerfile +++ b/packaging/docker/Dockerfile @@ -23,7 +23,8 @@ ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bi WORKDIR $GF_PATHS_HOME -RUN apk add --no-cache ca-certificates bash +RUN apk add --no-cache ca-certificates bash && \ + apk add --no-cache --upgrade --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main openssl musl-utils # PhantomJS RUN if [ `arch` = "x86_64" ]; then \ diff --git a/pkg/api/api.go b/pkg/api/api.go index f84299cbe624f..f82f973a72160 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -55,6 +55,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index) r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, hs.Index) r.Get("/admin/stats", reqGrafanaAdmin, hs.Index) + r.Get("/admin/ldap", reqGrafanaAdmin, hs.Index) r.Get("/styleguide", reqSignedIn, hs.Index) diff --git a/pkg/api/common.go b/pkg/api/common.go index 7973c72c8fa80..74bf42de7a810 100644 --- a/pkg/api/common.go +++ b/pkg/api/common.go @@ -135,3 +135,15 @@ func Respond(status int, body interface{}) *NormalResponse { header: make(http.Header), } } + +type RedirectResponse struct { + location string +} + +func (r *RedirectResponse) WriteTo(ctx *m.ReqContext) { + ctx.Redirect(r.location) +} + +func Redirect(location string) *RedirectResponse { + return &RedirectResponse{location: location} +} diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index e761461407649..1fce9895e8900 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -231,7 +231,6 @@ func GetDataSourceByName(c *m.ReqContext) Response { } dtos := convertModelToDtos(query.Result) - dtos.ReadOnly = true return JSON(200, &dtos) } diff --git a/pkg/api/index.go b/pkg/api/index.go index c22f3498a6a07..1c87b0d84fa5e 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -306,6 +306,19 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er }) if c.IsGrafanaAdmin { + adminNavLinks := []*dtos.NavLink{ + {Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"}, + {Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"}, + {Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"}, + {Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"}, + } + + if setting.LDAPEnabled { + adminNavLinks = append(adminNavLinks, &dtos.NavLink{ + Text: "LDAP", Id: "ldap", Url: setting.AppSubUrl + "/admin/ldap", Icon: "fa fa-fw fa-address-book-o", + }) + } + data.NavTree = append(data.NavTree, &dtos.NavLink{ Text: "Server Admin", SubTitle: "Manage all users & orgs", @@ -313,13 +326,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er Id: "admin", Icon: "gicon gicon-shield", Url: setting.AppSubUrl + "/admin/users", - Children: []*dtos.NavLink{ - {Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"}, - {Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"}, - {Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"}, - {Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"}, - {Text: "LDAP", Id: "ldap", Url: setting.AppSubUrl + "/admin/ldap", Icon: "fa fa-fw fa-address-book-o"}, - }, + Children: adminNavLinks, }) } diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go index b5f9d91bf4afd..de87c128a896c 100644 --- a/pkg/api/ldap_debug.go +++ b/pkg/api/ldap_debug.go @@ -34,7 +34,7 @@ type LDAPAttribute struct { } // RoleDTO is a serializer for mapped roles from LDAP -type RoleDTO struct { +type LDAPRoleDTO struct { OrgId int64 `json:"orgId"` OrgName string `json:"orgName"` OrgRole models.RoleType `json:"orgRole"` @@ -49,7 +49,7 @@ type LDAPUserDTO struct { Username *LDAPAttribute `json:"login"` IsGrafanaAdmin *bool `json:"isGrafanaAdmin"` IsDisabled bool `json:"isDisabled"` - OrgRoles []RoleDTO `json:"roles"` + OrgRoles []LDAPRoleDTO `json:"roles"` Teams []models.TeamOrgGroupDTO `json:"teams"` } @@ -90,6 +90,10 @@ func (user *LDAPUserDTO) FetchOrgs() error { } for i, orgDTO := range user.OrgRoles { + if orgDTO.OrgId < 1 { + continue + } + orgName := orgNamesById[orgDTO.OrgId] if orgName != "" { @@ -256,7 +260,7 @@ func (server *HTTPServer) GetUserFromLDAP(c *models.ReqContext) Response { user, serverConfig, err := ldap.User(username) if user == nil { - return Error(http.StatusNotFound, "No user was found on the LDAP server(s)", err) + return Error(http.StatusNotFound, "No user was found in the LDAP server(s) with that username", err) } logger.Debug("user found", "user", user) @@ -272,22 +276,32 @@ func (server *HTTPServer) GetUserFromLDAP(c *models.ReqContext) Response { IsDisabled: user.IsDisabled, } - orgRoles := []RoleDTO{} + orgRoles := []LDAPRoleDTO{} - for _, g := range serverConfig.Groups { - role := &RoleDTO{} + // First, let's find the groupDN that we did match by inspecting the assigned user OrgRoles. + for _, group := range serverConfig.Groups { + orgRole, ok := user.OrgRoles[group.OrgId] - if isMatchToLDAPGroup(user, g) { - role.OrgId = g.OrgID - role.OrgRole = user.OrgRoles[g.OrgID] - role.GroupDN = g.GroupDN + if ok && orgRole == group.OrgRole { + r := &LDAPRoleDTO{GroupDN: group.GroupDN, OrgId: group.OrgId, OrgRole: group.OrgRole} + orgRoles = append(orgRoles, *r) + } + } - orgRoles = append(orgRoles, *role) - } else { - role.OrgId = g.OrgID - role.GroupDN = g.GroupDN + // Then, we find what we did not match by inspecting the list of groups returned from + // LDAP against what we have already matched above. + for _, userGroup := range user.Groups { + var matches int - orgRoles = append(orgRoles, *role) + for _, orgRole := range orgRoles { + if orgRole.GroupDN == userGroup { // we already matched it + matches++ + } + } + + if matches < 1 { + r := &LDAPRoleDTO{GroupDN: userGroup} + orgRoles = append(orgRoles, *r) } } @@ -312,12 +326,6 @@ func (server *HTTPServer) GetUserFromLDAP(c *models.ReqContext) Response { return JSON(200, u) } -// isMatchToLDAPGroup determines if we were able to match an LDAP group to an organization+role. -// Since we allow one role per organization. If it's set, we were able to match it. -func isMatchToLDAPGroup(user *models.ExternalUserInfo, groupConfig *ldap.GroupToOrgRole) bool { - return user.OrgRoles[groupConfig.OrgID] == groupConfig.OrgRole -} - // splitName receives the full name of a user and splits it into two parts: A name and a surname. func splitName(name string) (string, string) { names := util.SplitString(name) diff --git a/pkg/api/ldap_debug_test.go b/pkg/api/ldap_debug_test.go index 938c3b1b22825..b5815503f6185 100644 --- a/pkg/api/ldap_debug_test.go +++ b/pkg/api/ldap_debug_test.go @@ -94,7 +94,7 @@ func TestGetUserFromLDAPApiEndpoint_UserNotFound(t *testing.T) { sc := getUserFromLDAPContext(t, "/api/admin/ldap/user-that-does-not-exist") require.Equal(t, sc.resp.Code, http.StatusNotFound) - assert.JSONEq(t, "{\"message\":\"No user was found on the LDAP server(s)\"}", sc.resp.Body.String()) + assert.JSONEq(t, "{\"message\":\"No user was found in the LDAP server(s) with that username\"}", sc.resp.Body.String()) } func TestGetUserFromLDAPApiEndpoint_OrgNotfound(t *testing.T) { @@ -103,6 +103,7 @@ func TestGetUserFromLDAPApiEndpoint_OrgNotfound(t *testing.T) { Name: "John Doe", Email: "john.doe@example.com", Login: "johndoe", + Groups: []string{"cn=admins,ou=groups,dc=grafana,dc=org"}, OrgRoles: map[int64]models.RoleType{1: models.ROLE_ADMIN, 2: models.ROLE_VIEWER}, IsGrafanaAdmin: &isAdmin, } @@ -117,12 +118,12 @@ func TestGetUserFromLDAPApiEndpoint_OrgNotfound(t *testing.T) { Groups: []*ldap.GroupToOrgRole{ { GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org", - OrgID: 1, + OrgId: 1, OrgRole: models.ROLE_ADMIN, }, { GroupDN: "cn=admins,ou=groups,dc=grafana2,dc=org", - OrgID: 2, + OrgId: 2, OrgRole: models.ROLE_VIEWER, }, }, @@ -164,6 +165,7 @@ func TestGetUserFromLDAPApiEndpoint(t *testing.T) { Name: "John Doe", Email: "john.doe@example.com", Login: "johndoe", + Groups: []string{"cn=admins,ou=groups,dc=grafana,dc=org", "another-group-not-matched"}, OrgRoles: map[int64]models.RoleType{1: models.ROLE_ADMIN}, IsGrafanaAdmin: &isAdmin, } @@ -178,7 +180,7 @@ func TestGetUserFromLDAPApiEndpoint(t *testing.T) { Groups: []*ldap.GroupToOrgRole{ { GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org", - OrgID: 1, + OrgId: 1, OrgRole: models.ROLE_ADMIN, }, }, @@ -203,7 +205,7 @@ func TestGetUserFromLDAPApiEndpoint(t *testing.T) { sc := getUserFromLDAPContext(t, "/api/admin/ldap/johndoe") - require.Equal(t, sc.resp.Code, http.StatusOK) + assert.Equal(t, sc.resp.Code, http.StatusOK) expected := ` { @@ -222,7 +224,8 @@ func TestGetUserFromLDAPApiEndpoint(t *testing.T) { "isGrafanaAdmin": true, "isDisabled": false, "roles": [ - { "orgId": 1, "orgRole": "Admin", "orgName": "Main Org.", "groupDN": "cn=admins,ou=groups,dc=grafana,dc=org" } + { "orgId": 1, "orgRole": "Admin", "orgName": "Main Org.", "groupDN": "cn=admins,ou=groups,dc=grafana,dc=org" }, + { "orgId": 0, "orgRole": "", "orgName": "", "groupDN": "another-group-not-matched" } ], "teams": null } @@ -251,7 +254,7 @@ func TestGetUserFromLDAPApiEndpoint_WithTeamHandler(t *testing.T) { Groups: []*ldap.GroupToOrgRole{ { GroupDN: "cn=admins,ou=groups,dc=grafana,dc=org", - OrgID: 1, + OrgId: 1, OrgRole: models.ROLE_ADMIN, }, }, diff --git a/pkg/infra/usagestats/usage_stats.go b/pkg/infra/usagestats/usage_stats.go index 96bf7df096de2..d6c165e766cd1 100644 --- a/pkg/infra/usagestats/usage_stats.go +++ b/pkg/infra/usagestats/usage_stats.go @@ -155,6 +155,10 @@ func (uss *UsageStatsService) sendUsageStats(oauthProviders map[string]bool) { } func (uss *UsageStatsService) updateTotalStats() { + if !uss.Cfg.MetricsEndpointEnabled || uss.Cfg.MetricsEndpointDisableTotalStats { + return + } + statsQuery := models.GetSystemStatsQuery{} if err := uss.Bus.Dispatch(&statsQuery); err != nil { metricsLogger.Error("Failed to get system stats", "error", err) diff --git a/pkg/infra/usagestats/usage_stats_test.go b/pkg/infra/usagestats/usage_stats_test.go index 07f2df2219242..45876fa304e0a 100644 --- a/pkg/infra/usagestats/usage_stats_test.go +++ b/pkg/infra/usagestats/usage_stats_test.go @@ -264,6 +264,49 @@ func TestMetrics(t *testing.T) { ts.Close() }) }) + + Convey("Test update total stats", t, func() { + uss := &UsageStatsService{ + Bus: bus.New(), + Cfg: setting.NewCfg(), + } + uss.Cfg.MetricsEndpointEnabled = true + uss.Cfg.MetricsEndpointDisableTotalStats = false + getSystemStatsWasCalled := false + uss.Bus.AddHandler(func(query *models.GetSystemStatsQuery) error { + query.Result = &models.SystemStats{} + getSystemStatsWasCalled = true + return nil + }) + + Convey("should not update stats when metrics is disabled and total stats is disabled", func() { + uss.Cfg.MetricsEndpointEnabled = false + uss.Cfg.MetricsEndpointDisableTotalStats = true + uss.updateTotalStats() + So(getSystemStatsWasCalled, ShouldBeFalse) + }) + + Convey("should not update stats when metrics is disabled and total stats enabled", func() { + uss.Cfg.MetricsEndpointEnabled = false + uss.Cfg.MetricsEndpointDisableTotalStats = false + uss.updateTotalStats() + So(getSystemStatsWasCalled, ShouldBeFalse) + }) + + Convey("should not update stats when metrics is enabled and total stats disabled", func() { + uss.Cfg.MetricsEndpointEnabled = true + uss.Cfg.MetricsEndpointDisableTotalStats = true + uss.updateTotalStats() + So(getSystemStatsWasCalled, ShouldBeFalse) + }) + + Convey("should update stats when metrics is enabled and total stats enabled", func() { + uss.Cfg.MetricsEndpointEnabled = true + uss.Cfg.MetricsEndpointDisableTotalStats = false + uss.updateTotalStats() + So(getSystemStatsWasCalled, ShouldBeTrue) + }) + }) } func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool { diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 6df4dcb34573f..9c8156f2a3ae9 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -144,9 +144,9 @@ type AddDataSourceCommand struct { IsDefault bool `json:"isDefault"` JsonData *simplejson.Json `json:"jsonData"` SecureJsonData map[string]string `json:"secureJsonData"` - ReadOnly bool `json:"readOnly"` - OrgId int64 `json:"-"` + OrgId int64 `json:"-"` + ReadOnly bool `json:"-"` Result *DataSource } @@ -168,10 +168,10 @@ type UpdateDataSourceCommand struct { JsonData *simplejson.Json `json:"jsonData"` SecureJsonData map[string]string `json:"secureJsonData"` Version int `json:"version"` - ReadOnly bool `json:"readOnly"` - OrgId int64 `json:"-"` - Id int64 `json:"-"` + OrgId int64 `json:"-"` + Id int64 `json:"-"` + ReadOnly bool `json:"-"` Result *DataSource } diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index 6ae6ec6ccf4d1..356375589b81b 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -36,11 +36,7 @@ func (r RoleType) Includes(other RoleType) bool { return other != ROLE_ADMIN } - if r == ROLE_VIEWER { - return other == ROLE_VIEWER - } - - return false + return r == other } func (r *RoleType) UnmarshalJSON(data []byte) error { diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index d079d632f25de..a6718ce69d9c1 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -408,12 +408,12 @@ func (server *Server) buildGrafanaUser(user *ldap.Entry) (*models.ExternalUserIn for _, group := range server.Config.Groups { // only use the first match for each org - if extUser.OrgRoles[group.OrgID] != "" { + if extUser.OrgRoles[group.OrgId] != "" { continue } if isMemberOf(memberOf, group.GroupDN) { - extUser.OrgRoles[group.OrgID] = group.OrgRole + extUser.OrgRoles[group.OrgId] = group.OrgRole if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin { extUser.IsGrafanaAdmin = group.IsGrafanaAdmin } diff --git a/pkg/services/ldap/ldap_private_test.go b/pkg/services/ldap/ldap_private_test.go index ff8bc430ed9a8..0382cdc59876d 100644 --- a/pkg/services/ldap/ldap_private_test.go +++ b/pkg/services/ldap/ldap_private_test.go @@ -3,11 +3,10 @@ package ldap import ( "testing" - . "github.com/smartystreets/goconvey/convey" - "gopkg.in/ldap.v3" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/ldap.v3" ) func TestLDAPPrivateMethods(t *testing.T) { @@ -124,7 +123,7 @@ func TestLDAPPrivateMethods(t *testing.T) { Config: &ServerConfig{ Groups: []*GroupToOrgRole{ { - OrgID: 1, + OrgId: 1, }, }, }, @@ -162,7 +161,7 @@ func TestLDAPPrivateMethods(t *testing.T) { Config: &ServerConfig{ Groups: []*GroupToOrgRole{ { - OrgID: 1, + OrgId: 1, }, }, }, diff --git a/pkg/services/ldap/settings.go b/pkg/services/ldap/settings.go index beba4c76c19af..720180716f60b 100644 --- a/pkg/services/ldap/settings.go +++ b/pkg/services/ldap/settings.go @@ -55,7 +55,7 @@ type AttributeMap struct { // config "group_mappings" setting type GroupToOrgRole struct { GroupDN string `toml:"group_dn"` - OrgID int64 `toml:"org_id"` + OrgId int64 `toml:"org_id"` // This pointer specifies if setting was set (for backwards compatibility) IsGrafanaAdmin *bool `toml:"grafana_admin"` @@ -139,8 +139,8 @@ func readConfig(configFile string) (*Config, error) { } for _, groupMap := range server.Groups { - if groupMap.OrgID == 0 { - groupMap.OrgID = 1 + if groupMap.OrgId == 0 { + groupMap.OrgId = 1 } } } diff --git a/pkg/services/provisioning/datasources/testdata/zero-datasources/placeholder-for-git b/pkg/services/provisioning/datasources/testdata/zero-datasources/placeholder-for-git deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index 1e4537a0e164e..71c8bf626b01c 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -96,22 +96,13 @@ func roleCounterSQL(role, alias string) string { return ` ( SELECT COUNT(*) - FROM ` + dialect.Quote("user") + ` as u - WHERE - (SELECT COUNT(*) - FROM org_user - WHERE org_user.user_id=u.id - AND org_user.role='` + role + `')>0 + FROM ` + dialect.Quote("user") + ` as u, org_user + WHERE ( org_user.user_id=u.id AND org_user.role='` + role + `' ) ) as ` + alias + `, ( SELECT COUNT(*) - FROM ` + dialect.Quote("user") + ` as u - WHERE - (SELECT COUNT(*) - FROM org_user - WHERE org_user.user_id=u.id - AND org_user.role='` + role + `')>0 - AND u.last_seen_at>? + FROM ` + dialect.Quote("user") + ` as u, org_user + WHERE u.last_seen_at>? AND ( org_user.user_id=u.id AND org_user.role='` + role + `' ) ) as active_` + alias } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index a0dd674508651..a9f74a1ad70bf 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -243,6 +243,7 @@ type Cfg struct { MetricsEndpointEnabled bool MetricsEndpointBasicAuthUsername string MetricsEndpointBasicAuthPassword string + MetricsEndpointDisableTotalStats bool PluginsEnableAlpha bool PluginsAppsSkipVerifyTLS bool DisableSanitizeHtml bool @@ -907,6 +908,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { if err != nil { return err } + cfg.MetricsEndpointDisableTotalStats = iniFile.Section("metrics").Key("disable_total_stats").MustBool(false) analytics := iniFile.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go index 527313b0260da..ec2a4ea2cd9ca 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-datasource.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-datasource.go @@ -60,7 +60,11 @@ func (e *AzureMonitorDatasource) executeTimeSeriesQuery(ctx context.Context, ori if err != nil { queryRes.Error = err } - result.Results[query.RefID] = queryRes + if val, ok := result.Results[query.RefID]; ok { + val.Series = append(result.Results[query.RefID].Series, queryRes.Series...) + } else { + result.Results[query.RefID] = queryRes + } } return result, nil @@ -84,11 +88,22 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange * azureMonitorTarget := query.Model.Get("azureMonitor").MustMap() azlog.Debug("AzureMonitor", "target", azureMonitorTarget) + queryMode := fmt.Sprintf("%v", azureMonitorTarget["queryMode"]) + if queryMode == "crossResource" { + return nil, fmt.Errorf("Alerting not supported for multiple resource queries") + } + + var azureMonitorData map[string]interface{} + if queryMode == "singleResource" { + azureMonitorData = azureMonitorTarget["data"].(map[string]interface{})[queryMode].(map[string]interface{}) + } else { + azureMonitorData = azureMonitorTarget + } urlComponents := map[string]string{} urlComponents["subscription"] = fmt.Sprintf("%v", query.Model.Get("subscription").MustString()) - urlComponents["resourceGroup"] = fmt.Sprintf("%v", azureMonitorTarget["resourceGroup"]) - urlComponents["metricDefinition"] = fmt.Sprintf("%v", azureMonitorTarget["metricDefinition"]) - urlComponents["resourceName"] = fmt.Sprintf("%v", azureMonitorTarget["resourceName"]) + urlComponents["resourceGroup"] = fmt.Sprintf("%v", azureMonitorData["resourceGroup"]) + urlComponents["metricDefinition"] = fmt.Sprintf("%v", azureMonitorData["metricDefinition"]) + urlComponents["resourceName"] = fmt.Sprintf("%v", azureMonitorData["resourceName"]) ub := urlBuilder{ DefaultSubscription: query.DataSource.JsonData.Get("subscriptionId").MustString(), @@ -100,12 +115,12 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange * azureURL := ub.Build() alias := "" - if val, ok := azureMonitorTarget["alias"]; ok { + if val, ok := azureMonitorData["alias"]; ok { alias = fmt.Sprintf("%v", val) } - timeGrain := fmt.Sprintf("%v", azureMonitorTarget["timeGrain"]) - timeGrains := azureMonitorTarget["allowedTimeGrainsMs"] + timeGrain := fmt.Sprintf("%v", azureMonitorData["timeGrain"]) + timeGrains := azureMonitorData["allowedTimeGrainsMs"] if timeGrain == "auto" { timeGrain, err = e.setAutoTimeGrain(query.IntervalMs, timeGrains) if err != nil { @@ -117,13 +132,16 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange * params.Add("api-version", "2018-01-01") params.Add("timespan", fmt.Sprintf("%v/%v", startTime.UTC().Format(time.RFC3339), endTime.UTC().Format(time.RFC3339))) params.Add("interval", timeGrain) - params.Add("aggregation", fmt.Sprintf("%v", azureMonitorTarget["aggregation"])) - params.Add("metricnames", fmt.Sprintf("%v", azureMonitorTarget["metricName"])) - params.Add("metricnamespace", fmt.Sprintf("%v", azureMonitorTarget["metricNamespace"])) + params.Add("aggregation", fmt.Sprintf("%v", azureMonitorData["aggregation"])) + params.Add("metricnames", fmt.Sprintf("%v", azureMonitorData["metricName"])) + + if val, ok := azureMonitorData["metricNamespace"]; ok { + params.Add("metricnamespace", fmt.Sprintf("%v", val)) + } - dimension := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorTarget["dimension"])) - dimensionFilter := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorTarget["dimensionFilter"])) - if azureMonitorTarget["dimension"] != nil && azureMonitorTarget["dimensionFilter"] != nil && len(dimension) > 0 && len(dimensionFilter) > 0 && dimension != "None" { + dimension := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorData["dimension"])) + dimensionFilter := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorData["dimensionFilter"])) + if azureMonitorData["dimension"] != nil && azureMonitorData["dimensionFilter"] != nil && len(dimension) > 0 && len(dimensionFilter) > 0 && dimension != "None" { params.Add("$filter", fmt.Sprintf("%s eq '%s'", dimension, dimensionFilter)) } diff --git a/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go b/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go index ada82190d1133..a748d9887658b 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-datasource_test.go @@ -36,15 +36,20 @@ func TestAzureMonitorDatasource(t *testing.T) { Model: simplejson.NewFromAny(map[string]interface{}{ "subscription": "12345678-aaaa-bbbb-cccc-123456789abc", "azureMonitor": map[string]interface{}{ - "timeGrain": "PT1M", - "aggregation": "Average", - "resourceGroup": "grafanastaging", - "resourceName": "grafana", - "metricDefinition": "Microsoft.Compute/virtualMachines", - "metricNamespace": "Microsoft.Compute-virtualMachines", - "metricName": "Percentage CPU", - "alias": "testalias", - "queryType": "Azure Monitor", + "queryMode": "singleResource", + "data": map[string]interface{}{ + "singleResource": map[string]interface{}{ + "timeGrain": "PT1M", + "aggregation": "Average", + "resourceGroup": "grafanastaging", + "resourceName": "grafana", + "metricDefinition": "Microsoft.Compute/virtualMachines", + "metricNamespace": "Microsoft.Compute-virtualMachines", + "metricName": "Percentage CPU", + "alias": "testalias", + "queryType": "Azure Monitor", + }, + }, }, }), RefId: "A", diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index c25ab2654421f..6f96d7f869f94 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -155,7 +155,7 @@ func init() { "AWS/Events": {"RuleName"}, "AWS/FSx": {}, "AWS/Firehose": {"DeliveryStreamName"}, - "AWS/GameLift": {"FleetId", "InstanceType", "MatchmakingConfigurationName", "MatchmakingConfigurationName-RuleName", "MetricGroup", "OperatingSystem", "QueueName"}, + "AWS/GameLift": {"FleetId", "InstanceType", "MatchmakingConfigurationName", "MatchmakingConfigurationName-RuleName", "MetricGroups", "OperatingSystem", "QueueName"}, "AWS/Glue": {"JobName", "JobRunId", "Type"}, "AWS/Inspector": {}, "AWS/IoT": {"ActionType", "BehaviorName", "CheckName", "JobId", "Protocol", "RuleName", "ScheduledAuditName", "SecurityProfileName"}, diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 692142246dad9..8f6176c41f3f7 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -3,7 +3,6 @@ package mssql import ( "database/sql" "fmt" - "net/url" "strconv" "github.com/grafana/grafana/pkg/setting" @@ -24,7 +23,10 @@ func init() { func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) { logger := log.New("tsdb.mssql") - cnnstr := generateConnectionString(datasource) + cnnstr, err := generateConnectionString(datasource) + if err != nil { + return nil, err + } if setting.Env == setting.DEV { logger.Debug("getEngine", "connection", cnnstr) } @@ -43,21 +45,21 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin return sqleng.NewSqlQueryEndpoint(&config, &rowTransformer, newMssqlMacroEngine(), logger) } -func generateConnectionString(datasource *models.DataSource) string { +func generateConnectionString(datasource *models.DataSource) (string, error) { server, port := util.SplitHostPortDefault(datasource.Url, "localhost", "1433") - encrypt := datasource.JsonData.Get("encrypt").MustString("false") - query := url.Values{} - query.Add("database", datasource.Database) - query.Add("encrypt", encrypt) - - u := &url.URL{ - Scheme: "sqlserver", - User: url.UserPassword(datasource.User, datasource.DecryptedPassword()), - Host: fmt.Sprintf("%s:%s", server, port), - RawQuery: query.Encode(), + encrypt := datasource.JsonData.Get("encrypt").MustString("false") + connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;", + server, + port, + datasource.Database, + datasource.User, + datasource.DecryptedPassword(), + ) + if encrypt != "false" { + connStr += fmt.Sprintf("encrypt=%s;", encrypt) } - return u.String() + return connStr, nil } type mssqlRowTransformer struct { diff --git a/pkg/tsdb/mssql/mssql_test.go b/pkg/tsdb/mssql/mssql_test.go index 940d26e4d1cd7..aef4a9a0d7225 100644 --- a/pkg/tsdb/mssql/mssql_test.go +++ b/pkg/tsdb/mssql/mssql_test.go @@ -28,33 +28,6 @@ import ( // If needed, change the variable below to the IP address of the database. var serverIP = "localhost" -func TestGenerateConnectionString(t *testing.T) { - encrypted, _ := simplejson.NewJson([]byte(`{"encrypt":"false"}`)) - testSet := []struct { - ds *models.DataSource - expected string - }{ - { - &models.DataSource{ - User: "user", - Database: "db", - Url: "localhost:1433", - SecureJsonData: securejsondata.GetEncryptedJsonData(map[string]string{ - "password": "pass;word", - }), - JsonData: encrypted, - }, - "sqlserver://user:pass;word@localhost:1433?database=db&encrypt=false", - }, - } - for i := range testSet { - got := generateConnectionString(testSet[i].ds) - if got != testSet[i].expected { - t.Errorf("mssql connString error for testCase %d got: %s expected: %s", i, got, testSet[i].expected) - } - } -} - func TestMSSQL(t *testing.T) { SkipConvey("MSSQL", t, func() { x := InitMSSQLTestDB(t) diff --git a/public/app/core/components/PageLoader/PageLoader.tsx b/public/app/core/components/PageLoader/PageLoader.tsx index 6deeabf9a4133..ff4f4dc4c20c3 100644 --- a/public/app/core/components/PageLoader/PageLoader.tsx +++ b/public/app/core/components/PageLoader/PageLoader.tsx @@ -1,4 +1,5 @@ import React, { FC } from 'react'; +import { LoadingPlaceholder } from '@grafana/ui'; interface Props { pageName?: string; @@ -8,8 +9,7 @@ const PageLoader: FC = ({ pageName = '' }) => { const loadingText = `Loading ${pageName}...`; return (

- -
{loadingText}
+
); }; diff --git a/public/app/core/components/SafeDynamicImport.tsx b/public/app/core/components/SafeDynamicImport.tsx new file mode 100644 index 0000000000000..11eba3b7ef799 --- /dev/null +++ b/public/app/core/components/SafeDynamicImport.tsx @@ -0,0 +1,52 @@ +import React, { lazy, Suspense, FunctionComponent } from 'react'; +import { cx, css } from 'emotion'; +import { LoadingPlaceholder, ErrorBoundary, Button } from '@grafana/ui'; + +export const LoadingChunkPlaceHolder: FunctionComponent = () => ( +
+ +
+); + +function getAlertPageStyle() { + return css` + width: 508px; + margin: 128px auto; + `; +} + +export const SafeDynamicImport = (importStatement: Promise) => ({ ...props }) => { + const LazyComponent = lazy(() => importStatement); + return ( + + {({ error, errorInfo }) => { + if (!errorInfo) { + return ( + }> + + + ); + } + + return ( +
+

Unable to find application file

+
+

Grafana has likely been updated. Please try reloading the page.

+
+
+ +
+
+ {error && error.toString()} +
+ {errorInfo.componentStack} +
+
+ ); + }} +
+ ); +}; diff --git a/public/app/features/admin/ldap/LdapPage.tsx b/public/app/features/admin/ldap/LdapPage.tsx index 57b865badc30b..9f65470ff749d 100644 --- a/public/app/features/admin/ldap/LdapPage.tsx +++ b/public/app/features/admin/ldap/LdapPage.tsx @@ -89,12 +89,12 @@ export class LdapPage extends PureComponent { {config.buildInfo.isEnterprise && ldapSyncInfo && } -

User mapping

+

Test user mapping

- +
diff --git a/public/app/features/admin/ldap/LdapUserGroups.tsx b/public/app/features/admin/ldap/LdapUserGroups.tsx index d38fbb956cceb..a21cd4da876e5 100644 --- a/public/app/features/admin/ldap/LdapUserGroups.tsx +++ b/public/app/features/admin/ldap/LdapUserGroups.tsx @@ -9,7 +9,6 @@ interface Props { export const LdapUserGroups: FC = ({ groups, showAttributeMapping }) => { const items = showAttributeMapping ? groups : groups.filter(item => item.orgRole); - const roleColumnClass = showAttributeMapping && 'width-14'; return (
@@ -17,32 +16,39 @@ export const LdapUserGroups: FC = ({ groups, showAttributeMapping }) => { + {showAttributeMapping && } - {showAttributeMapping && } {items.map((group, index) => { return ( - - {showAttributeMapping && ( <> - + {!group.orgRole && ( + <> + + + )} + + )} + {group.orgName && ( + <> + + )} diff --git a/public/app/features/admin/ldap/LdapUserInfo.tsx b/public/app/features/admin/ldap/LdapUserInfo.tsx index 3aed200c4df8b..c1c3298ccb199 100644 --- a/public/app/features/admin/ldap/LdapUserInfo.tsx +++ b/public/app/features/admin/ldap/LdapUserInfo.tsx @@ -18,8 +18,21 @@ export const LdapUserInfo: FC = ({ ldapUser, showAttributeMapping }) => { {ldapUser.roles && ldapUser.roles.length > 0 && ( )} - {ldapUser.teams && ldapUser.teams.length > 0 && ( + + {ldapUser.teams && ldapUser.teams.length > 0 ? ( + ) : ( +
+
+
LDAP GroupOrganisation RoleLDAP Group
{group.orgName}{group.orgRole}{group.groupDN} - {!group.orgRole && ( - - No match - -
- -
-
-
- )} -
+ + + No match + + + + + + + {group.orgName}{group.orgRole}
+ + + + + +
No teams found via LDAP
+
+
)} ); diff --git a/public/app/features/admin/ldap/LdapUserTeams.tsx b/public/app/features/admin/ldap/LdapUserTeams.tsx index 30ac81b52310c..e8a87d2bfaab4 100644 --- a/public/app/features/admin/ldap/LdapUserTeams.tsx +++ b/public/app/features/admin/ldap/LdapUserTeams.tsx @@ -1,5 +1,4 @@ import React, { FC } from 'react'; -import { css } from 'emotion'; import { Tooltip } from '@grafana/ui'; import { LdapTeam } from 'app/types'; @@ -10,10 +9,6 @@ interface Props { export const LdapUserTeams: FC = ({ teams, showAttributeMapping }) => { const items = showAttributeMapping ? teams : teams.filter(item => item.teamName); - const teamColumnClass = showAttributeMapping && 'width-14'; - const noMatchPlaceholderStyle = css` - display: flex; - `; return (
@@ -21,29 +16,41 @@ export const LdapUserTeams: FC = ({ teams, showAttributeMapping }) => { + {showAttributeMapping && } - {showAttributeMapping && } {items.map((team, index) => { return ( - - - {showAttributeMapping && } + {showAttributeMapping && ( + <> + + {!team.orgName && ( + <> + + + )} + + )} + {team.orgName && ( + <> + + + + )} ); })} diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts index 93501db91d86b..06fda51b6af0b 100644 --- a/public/app/features/admin/state/actions.ts +++ b/public/app/features/admin/state/actions.ts @@ -38,6 +38,7 @@ export function loadLdapState(): ThunkResult { const connectionInfo = await getLdapState(); dispatch(ldapConnectionInfoLoadedAction(connectionInfo)); } catch (error) { + error.isHandled = true; const ldapError = { title: error.data.message, body: error.data.error, @@ -63,6 +64,7 @@ export function loadUserMapping(username: string): ThunkResult { const userInfo = await getUserInfo(username); dispatch(userMappingInfoLoadedAction(userInfo)); } catch (error) { + error.isHandled = true; const userError = { title: error.data.message, body: error.data.error, @@ -106,6 +108,7 @@ export function loadLdapUserInfo(userId: number): ThunkResult { dispatch(loadUserSessions(userId)); dispatch(loadUserMapping(user.login)); } catch (error) { + error.isHandled = true; const userError = { title: error.data.message, body: error.data.error, diff --git a/public/app/features/admin/state/apis.ts b/public/app/features/admin/state/apis.ts index f7194c6208632..ea416ad4d3562 100644 --- a/public/app/features/admin/state/apis.ts +++ b/public/app/features/admin/state/apis.ts @@ -47,18 +47,14 @@ export const syncLdapUser = async (userId: number) => { }; export const getUserInfo = async (username: string): Promise => { - try { - const response = await getBackendSrv().get(`/api/admin/ldap/${username}`); - const { name, surname, email, login, isGrafanaAdmin, isDisabled, roles, teams } = response; - return { - info: { name, surname, email, login }, - permissions: { isGrafanaAdmin, isDisabled }, - roles, - teams, - }; - } catch (error) { - throw error; - } + const response = await getBackendSrv().get(`/api/admin/ldap/${username}`); + const { name, surname, email, login, isGrafanaAdmin, isDisabled, roles, teams } = response; + return { + info: { name, surname, email, login }, + permissions: { isGrafanaAdmin, isDisabled }, + roles, + teams, + }; }; export const getUser = async (id: number): Promise => { diff --git a/public/app/features/alerting/AlertTab.tsx b/public/app/features/alerting/AlertTab.tsx index 91fe65c0b9cdf..c7e880c7dd4d1 100644 --- a/public/app/features/alerting/AlertTab.tsx +++ b/public/app/features/alerting/AlertTab.tsx @@ -135,7 +135,10 @@ export class AlertTab extends PureComponent { if (!alert && hasTransformations) { return ( - + ); } diff --git a/public/app/features/explore/ExploreTimeControls.tsx b/public/app/features/explore/ExploreTimeControls.tsx index 01c9f5139757b..53b1fbce389ac 100644 --- a/public/app/features/explore/ExploreTimeControls.tsx +++ b/public/app/features/explore/ExploreTimeControls.tsx @@ -8,7 +8,7 @@ import { TimeRange, TimeOption, TimeZone, RawTimeRange, dateTimeForTimeZone } fr // State // Components -import { TimePicker, RefreshPicker, SetInterval } from '@grafana/ui'; +import { TimePicker } from '@grafana/ui'; // Utils & Services import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker'; @@ -16,14 +16,8 @@ import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePick export interface Props { exploreId: ExploreId; - hasLiveOption: boolean; - isLive: boolean; - loading: boolean; range: TimeRange; - refreshInterval: string; timeZone: TimeZone; - onRunQuery: () => void; - onChangeRefreshInterval: (interval: string) => void; onChangeTime: (range: RawTimeRange) => void; } @@ -73,40 +67,18 @@ export class ExploreTimeControls extends Component { }; render() { - const { - hasLiveOption, - isLive, - loading, - range, - refreshInterval, - timeZone, - onRunQuery, - onChangeRefreshInterval, - } = this.props; + const { range, timeZone } = this.props; return ( - <> - {!isLive && ( - - )} - - - {refreshInterval && } - + ); } } diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index ba57b3030f7a8..1687e1b04759c 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -5,8 +5,17 @@ import { hot } from 'react-hot-loader'; import memoizeOne from 'memoize-one'; import classNames from 'classnames'; -import { ExploreId, ExploreMode } from 'app/types/explore'; -import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton, DataQuery, Tooltip, ButtonSelect } from '@grafana/ui'; +import { ExploreId, ExploreItemState, ExploreMode } from 'app/types/explore'; +import { + DataSourceSelectItem, + ToggleButtonGroup, + ToggleButton, + DataQuery, + Tooltip, + ButtonSelect, + RefreshPicker, + SetInterval, +} from '@grafana/ui'; import { RawTimeRange, TimeZone, TimeRange, SelectableValue } from '@grafana/data'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { StoreState } from 'app/types/store'; @@ -20,44 +29,15 @@ import { changeMode, clearOrigin, } from './state/actions'; +import { changeRefreshIntervalAction, setPausedStateAction } from './state/actionTypes'; import { updateLocation } from 'app/core/actions'; import { getTimeZone } from '../profile/state/selectors'; import { getDashboardSrv } from '../dashboard/services/DashboardSrv'; import kbn from '../../core/utils/kbn'; import { ExploreTimeControls } from './ExploreTimeControls'; - -enum IconSide { - left = 'left', - right = 'right', -} - -const createResponsiveButton = (options: { - splitted: boolean; - title: string; - onClick: () => void; - buttonClassName?: string; - iconClassName?: string; - iconSide?: IconSide; - disabled?: boolean; -}) => { - const defaultOptions = { - iconSide: IconSide.left, - }; - const props = { ...options, defaultOptions }; - const { title, onClick, buttonClassName, iconClassName, splitted, iconSide, disabled } = props; - - return ( - - ); -}; +import { LiveTailButton } from './LiveTailButton'; +import { ResponsiveButton } from './ResponsiveButton'; +import { RunButton } from './RunButton'; interface OwnProps { exploreId: ExploreId; @@ -77,6 +57,7 @@ interface StateProps { selectedModeOption: SelectableValue; hasLiveOption: boolean; isLive: boolean; + isPaused: boolean; originPanelId: number; queries: DataQuery[]; } @@ -91,6 +72,8 @@ interface DispatchProps { changeMode: typeof changeMode; clearOrigin: typeof clearOrigin; updateLocation: typeof updateLocation; + changeRefreshIntervalAction: typeof changeRefreshIntervalAction; + setPausedStateAction: typeof setPausedStateAction; } type Props = StateProps & DispatchProps & OwnProps; @@ -147,6 +130,28 @@ export class UnConnectedExploreToolbar extends PureComponent { }); }; + stopLive = () => { + const { exploreId } = this.props; + // TODO referencing this from perspective of refresh picker when there is designated button for it now is not + // great. Needs another refactor. + this.props.changeRefreshIntervalAction({ exploreId, refreshInterval: RefreshPicker.offOption.value }); + }; + + startLive = () => { + const { exploreId } = this.props; + this.props.changeRefreshIntervalAction({ exploreId, refreshInterval: RefreshPicker.liveOption.value }); + }; + + pauseLive = () => { + const { exploreId } = this.props; + this.props.setPausedStateAction({ exploreId, isPaused: true }); + }; + + resumeLive = () => { + const { exploreId } = this.props; + this.props.setPausedStateAction({ exploreId, isPaused: false }); + }; + render() { const { datasourceMissing, @@ -165,6 +170,7 @@ export class UnConnectedExploreToolbar extends PureComponent { selectedModeOption, hasLiveOption, isLive, + isPaused, originPanelId, } = this.props; @@ -249,30 +255,25 @@ export class UnConnectedExploreToolbar extends PureComponent { {exploreId === 'left' && !splitted ? (
- {createResponsiveButton({ - splitted, - title: 'Split', - onClick: split, - iconClassName: 'fa fa-fw fa-columns icon-margin-right', - iconSide: IconSide.left, - disabled: isLive, - })} +
) : null} -
- -
+ {!isLive && ( +
+ +
+ )}
- {createResponsiveButton({ - splitted, - title: 'Run Query', - onClick: this.onRunQuery, - buttonClassName: 'navbar-button--secondary', - iconClassName: - loading && !isLive ? 'fa fa-spinner fa-fw fa-spin run-icon' : 'fa fa-level-down fa-fw run-icon', - iconSide: IconSide.right, - })} + + {refreshInterval && }
+ + {hasLiveOption && ( + + )} @@ -334,7 +346,7 @@ const getModeOptionsMemoized = memoizeOne( const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => { const splitted = state.explore.split; - const exploreItem = state.explore[exploreId]; + const exploreItem: ExploreItemState = state.explore[exploreId]; const { datasourceInstance, datasourceMissing, @@ -345,6 +357,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps supportedModes, mode, isLive, + isPaused, originPanelId, queries, } = exploreItem; @@ -369,6 +382,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps selectedModeOption, hasLiveOption, isLive, + isPaused, originPanelId, queries, }; @@ -384,6 +398,8 @@ const mapDispatchToProps: DispatchProps = { split: splitOpen, changeMode: changeMode, clearOrigin, + changeRefreshIntervalAction, + setPausedStateAction, }; export const ExploreToolbar = hot(module)( diff --git a/public/app/features/explore/LiveTailButton.tsx b/public/app/features/explore/LiveTailButton.tsx new file mode 100644 index 0000000000000..25897536b7109 --- /dev/null +++ b/public/app/features/explore/LiveTailButton.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import classNames from 'classnames'; +import { css } from 'emotion'; +import memoizeOne from 'memoize-one'; +import { GrafanaTheme, GrafanaThemeType, useTheme } from '@grafana/ui'; +import tinycolor from 'tinycolor2'; + +const orangeDark = '#FF780A'; +const orangeDarkLighter = tinycolor(orangeDark) + .lighten(10) + .toString(); +const orangeLight = '#ED5700'; +const orangeLightLighter = tinycolor(orangeLight) + .lighten(10) + .toString(); + +const getStyles = memoizeOne((theme: GrafanaTheme) => { + const orange = theme.type === GrafanaThemeType.Dark ? orangeDark : orangeLight; + const orangeLighter = theme.type === GrafanaThemeType.Dark ? orangeDarkLighter : orangeLightLighter; + const textColor = theme.type === GrafanaThemeType.Dark ? theme.colors.white : theme.colors.black; + + return { + noRightBorderStyle: css` + label: noRightBorderStyle; + border-right: 0; + `, + isLive: css` + label: isLive; + border-color: ${orange}; + color: ${orange}; + background: transparent; + &:focus { + border-color: ${orange}; + color: ${orange}; + } + &:active, + &:hover { + border-color: ${orangeLighter}; + color: ${orangeLighter}; + } + `, + isPaused: css` + label: isPaused; + border-color: ${orange}; + background: transparent; + animation: pulse 2s ease-out 0s infinite normal forwards; + &:focus { + border-color: ${orange}; + } + &:active, + &:hover { + border-color: ${orangeLighter}; + } + @keyframes pulse { + 0% { + color: ${textColor}; + } + 50% { + color: ${orange}; + } + 100% { + color: ${textColor}; + } + } + `, + }; +}); + +type LiveTailButtonProps = { + start: () => void; + stop: () => void; + pause: () => void; + resume: () => void; + isLive: boolean; + isPaused: boolean; +}; +export function LiveTailButton(props: LiveTailButtonProps) { + const { start, pause, resume, isLive, isPaused, stop } = props; + const theme = useTheme(); + const styles = getStyles(theme); + + const onClickMain = isLive ? (isPaused ? resume : pause) : start; + + return ( +
+ + {isLive && ( + + )} +
+ ); +} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 42d61d9b8bdcf..9d780b05d57f2 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -import { DataSourceApi, Collapse } from '@grafana/ui'; +import { DataSourceApi, Collapse, RefreshPicker } from '@grafana/ui'; import { RawTimeRange, @@ -26,7 +26,6 @@ import { import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors'; import { getTimeZone } from '../profile/state/selectors'; import { LiveLogsWithTheme } from './LiveLogs'; -import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; import { Logs } from './Logs'; interface LogsContainerProps { @@ -65,7 +64,7 @@ export class LogsContainer extends PureComponent { onStopLive = () => { const { exploreId } = this.props; - this.props.stopLive({ exploreId, refreshInterval: offOption.value }); + this.props.stopLive({ exploreId, refreshInterval: RefreshPicker.offOption.value }); }; onPause = () => { diff --git a/public/app/features/explore/ResponsiveButton.tsx b/public/app/features/explore/ResponsiveButton.tsx new file mode 100644 index 0000000000000..33aa2fedebf33 --- /dev/null +++ b/public/app/features/explore/ResponsiveButton.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +export enum IconSide { + left = 'left', + right = 'right', +} + +type Props = { + splitted: boolean; + title: string; + onClick: () => void; + buttonClassName?: string; + iconClassName?: string; + iconSide?: IconSide; + disabled?: boolean; +}; + +export const ResponsiveButton = (props: Props) => { + const defaultProps = { + iconSide: IconSide.left, + }; + props = { ...defaultProps, ...props }; + const { title, onClick, buttonClassName, iconClassName, splitted, iconSide, disabled } = props; + + return ( + + ); +}; diff --git a/public/app/features/explore/RunButton.tsx b/public/app/features/explore/RunButton.tsx new file mode 100644 index 0000000000000..b704d584cbace --- /dev/null +++ b/public/app/features/explore/RunButton.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { RefreshPicker } from '@grafana/ui'; +import memoizeOne from 'memoize-one'; +import { css } from 'emotion'; + +import { ResponsiveButton } from './ResponsiveButton'; + +const getStyles = memoizeOne(() => { + return { + selectButtonOverride: css` + label: selectButtonOverride; + .select-button-value { + color: white !important; + } + `, + }; +}); + +type Props = { + splitted: boolean; + loading: boolean; + onRun: () => void; + refreshInterval: string; + onChangeRefreshInterval: (interval: string) => void; + showDropdown: boolean; +}; + +export function RunButton(props: Props) { + const { splitted, loading, onRun, onChangeRefreshInterval, refreshInterval, showDropdown } = props; + const styles = getStyles(); + const runButton = ( + + ); + + if (showDropdown) { + return ( + + ); + } + return runButton; +} diff --git a/public/app/features/explore/utils/ResultProcessor.test.ts b/public/app/features/explore/utils/ResultProcessor.test.ts index 30860f5d7721c..efe8ee84eca70 100644 --- a/public/app/features/explore/utils/ResultProcessor.test.ts +++ b/public/app/features/explore/utils/ResultProcessor.test.ts @@ -1,4 +1,4 @@ -jest.mock('@grafana/data/src/utils/moment_wrapper', () => ({ +jest.mock('@grafana/data/src/datetime/moment_wrapper', () => ({ dateTime: (ts: any) => { return { valueOf: () => ts, diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 1425eeb32266f..f60661d26bf42 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -37,7 +37,8 @@ import * as grafanaUI from '@grafana/ui'; import * as grafanaRuntime from '@grafana/runtime'; // rxjs -import { Observable, Subject } from 'rxjs'; +import * as rxjs from 'rxjs'; +import * as rxjsOperators from 'rxjs/operators'; // add cache busting const bust = `?_cache=${Date.now()}`; @@ -81,12 +82,8 @@ exposeToPlugin('moment', moment); exposeToPlugin('jquery', jquery); exposeToPlugin('angular', angular); exposeToPlugin('d3', d3); -exposeToPlugin('rxjs/Subject', Subject); -exposeToPlugin('rxjs/Observable', Observable); -exposeToPlugin('rxjs', { - Subject: Subject, - Observable: Observable, -}); +exposeToPlugin('rxjs', rxjs); +exposeToPlugin('rxjs/operators', rxjsOperators); // Experimental modules exposeToPlugin('prismjs', prismjs); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts index 4e262044f65e8..69a542bb75048 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.test.ts @@ -918,8 +918,8 @@ describe('AzureMonitorDatasource', () => { 'nodeapp', 'microsoft.insights/components', 'resource1', - 'default', - 'UsedCapacity' + 'UsedCapacity', + 'default' ) .then((results: any) => { expect(results.primaryAggType).toEqual('Total'); @@ -992,8 +992,8 @@ describe('AzureMonitorDatasource', () => { 'nodeapp', 'microsoft.insights/components', 'resource1', - 'default', - 'Transactions' + 'Transactions', + 'default' ) .then((results: any) => { expect(results.dimensions.length).toEqual(4); @@ -1011,8 +1011,8 @@ describe('AzureMonitorDatasource', () => { 'nodeapp', 'microsoft.insights/components', 'resource1', - 'default', - 'FreeCapacity' + 'FreeCapacity', + 'default' ) .then((results: any) => { expect(results.dimensions.length).toEqual(0); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts index 4eb0c20315120..cd7033dc74ac3 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts @@ -5,9 +5,12 @@ import SupportedNamespaces from './supported_namespaces'; import TimegrainConverter from '../time_grain_converter'; import { AzureMonitorQuery, + AzureMonitorQueryData, AzureDataSourceJsonData, AzureMonitorMetricDefinitionsResponse, AzureMonitorResourceGroupsResponse, + AzureMonitorResourceResponse, + Resource, } from '../types'; import { DataQueryRequest, DataQueryResponseData, DataSourceInstanceSettings } from '@grafana/ui'; @@ -47,60 +50,172 @@ export default class AzureMonitorDatasource { return !!this.subscriptionId && this.subscriptionId.length > 0; } - async query(options: DataQueryRequest): Promise { - const queries = _.filter(options.targets, item => { - return ( - item.hide !== true && - item.azureMonitor.resourceGroup && - item.azureMonitor.resourceGroup !== this.defaultDropdownValue && - item.azureMonitor.resourceName && - item.azureMonitor.resourceName !== this.defaultDropdownValue && - item.azureMonitor.metricDefinition && - item.azureMonitor.metricDefinition !== this.defaultDropdownValue && - item.azureMonitor.metricName && - item.azureMonitor.metricName !== this.defaultDropdownValue - ); - }).map(target => { - const item = target.azureMonitor; + buildQuery( + options: DataQueryRequest, + target: any, + { + resourceGroup, + resourceName, + metricDefinition, + timeGrainUnit, + timeGrain, + metricName, + metricNamespace, + allowedTimeGrainsMs, + aggregation, + dimension, + dimensionFilter, + alias, + }: AzureMonitorQueryData, + subscriptionId?: string + ) { + if (timeGrainUnit && timeGrain !== 'auto') { + timeGrain = TimegrainConverter.createISO8601Duration(timeGrain, timeGrainUnit); + } - // fix for timeGrainUnit which is a deprecated/removed field name - if (item.timeGrainUnit && item.timeGrain !== 'auto') { - item.timeGrain = TimegrainConverter.createISO8601Duration(item.timeGrain, item.timeGrainUnit); - } + const metricNamespaceParsed = this.templateSrv.replace(metricNamespace, options.scopedVars); + + return { + refId: target.refId, + intervalMs: options.intervalMs, + datasourceId: this.id, + subscription: this.templateSrv.replace( + subscriptionId || target.subscription || this.subscriptionId, + options.scopedVars + ), + queryType: 'Azure Monitor', + type: 'timeSeriesQuery', + raw: false, + azureMonitor: { + resourceGroup: this.templateSrv.replace(resourceGroup, options.scopedVars), + resourceName: this.templateSrv.replace(resourceName, options.scopedVars), + metricDefinition: this.templateSrv.replace(metricDefinition, options.scopedVars), + timeGrain: this.templateSrv.replace((timeGrain || '').toString(), options.scopedVars), + allowedTimeGrainsMs: allowedTimeGrainsMs, + metricName: this.templateSrv.replace(metricName, options.scopedVars), + metricNamespace: + metricNamespaceParsed && metricNamespaceParsed !== this.defaultDropdownValue + ? metricNamespaceParsed + : metricDefinition, + aggregation: this.templateSrv.replace(aggregation, options.scopedVars), + dimension: this.templateSrv.replace(dimension, options.scopedVars), + dimensionFilter: this.templateSrv.replace(dimensionFilter, options.scopedVars), + alias, + format: target.format, + }, + }; + } - const subscriptionId = this.templateSrv.replace(target.subscription || this.subscriptionId, options.scopedVars); - const resourceGroup = this.templateSrv.replace(item.resourceGroup, options.scopedVars); - const resourceName = this.templateSrv.replace(item.resourceName, options.scopedVars); - const metricNamespace = this.templateSrv.replace(item.metricNamespace, options.scopedVars); - const metricDefinition = this.templateSrv.replace(item.metricDefinition, options.scopedVars); - const timeGrain = this.templateSrv.replace((item.timeGrain || '').toString(), options.scopedVars); - const aggregation = this.templateSrv.replace(item.aggregation, options.scopedVars); + buildSingleQuery( + options: DataQueryRequest, + target: any, + { + resourceGroup, + resourceName, + metricDefinition, + timeGrainUnit, + timeGrain, + metricName, + metricNamespace, + allowedTimeGrainsMs, + aggregation, + dimension, + dimensionFilter, + alias, + }: AzureMonitorQueryData, + queryMode: string + ) { + if (timeGrainUnit && timeGrain !== 'auto') { + timeGrain = TimegrainConverter.createISO8601Duration(timeGrain, timeGrainUnit); + } - return { - refId: target.refId, - intervalMs: options.intervalMs, - datasourceId: this.id, - subscription: subscriptionId, - queryType: 'Azure Monitor', - type: 'timeSeriesQuery', - raw: false, - azureMonitor: { - resourceGroup: resourceGroup, - resourceName: resourceName, - metricDefinition: metricDefinition, - timeGrain: timeGrain, - allowedTimeGrainsMs: item.allowedTimeGrainsMs, - metricName: this.templateSrv.replace(item.metricName, options.scopedVars), - metricNamespace: - metricNamespace && metricNamespace !== this.defaultDropdownValue ? metricNamespace : metricDefinition, - aggregation: aggregation, - dimension: this.templateSrv.replace(item.dimension, options.scopedVars), - dimensionFilter: this.templateSrv.replace(item.dimensionFilter, options.scopedVars), - alias: item.alias, - format: target.format, + const metricNamespaceParsed = this.templateSrv.replace(metricNamespace, options.scopedVars); + + return { + refId: target.refId, + intervalMs: options.intervalMs, + datasourceId: this.id, + subscription: this.templateSrv.replace(target.subscription || this.subscriptionId, options.scopedVars), + queryType: 'Azure Monitor', + type: 'timeSeriesQuery', + raw: false, + azureMonitor: { + queryMode, + data: { + [queryMode]: { + resourceGroup: this.templateSrv.replace(resourceGroup, options.scopedVars), + resourceName: this.templateSrv.replace(resourceName, options.scopedVars), + metricDefinition: this.templateSrv.replace(metricDefinition, options.scopedVars), + timeGrain: this.templateSrv.replace((timeGrain || '').toString(), options.scopedVars), + allowedTimeGrainsMs: allowedTimeGrainsMs, + metricName: this.templateSrv.replace(metricName, options.scopedVars), + metricNamespace: + metricNamespaceParsed && metricNamespaceParsed !== this.defaultDropdownValue + ? metricNamespaceParsed + : metricDefinition, + aggregation: this.templateSrv.replace(aggregation, options.scopedVars), + dimension: this.templateSrv.replace(dimension, options.scopedVars), + dimensionFilter: this.templateSrv.replace(dimensionFilter, options.scopedVars), + alias, + format: target.format, + }, }, - }; - }); + }, + }; + } + + async query(options: DataQueryRequest): Promise { + const groupedQueries: any[] = await Promise.all( + options.targets + .filter(item => { + const { data, queryMode } = item.azureMonitor; + const { resourceGroup, resourceGroups, metricDefinition, metricName } = data[queryMode]; + + return ( + item.hide !== true && + ((resourceGroup && resourceGroup !== this.defaultDropdownValue) || resourceGroups.length) && + metricDefinition && + metricDefinition !== this.defaultDropdownValue && + metricName && + metricName !== this.defaultDropdownValue + ); + }) + .map(async target => { + const { data, queryMode } = target.azureMonitor; + + if (queryMode === 'crossResource') { + const { resourceGroups, metricDefinition, locations } = data[queryMode]; + const resources = await this.getResources(target.subscriptions).then(resources => + resources.filter( + ({ type, group, subscriptionId, location }) => + target.subscriptions.includes(subscriptionId) && + resourceGroups.includes(group) && + locations.includes(location) && + metricDefinition === type + ) + ); + delete data.crossResource.metricNamespace; + return resources.map( + ({ type: metricDefinition, group: resourceGroup, subscriptionId, name: resourceName }) => + this.buildQuery( + options, + target, + { + ...data[queryMode], + metricDefinition, + resourceGroup, + resourceName, + }, + subscriptionId + ) + ); + } else { + return Promise.resolve(this.buildSingleQuery(options, target, data[queryMode], queryMode)); + } + }) + ); + + const queries = _.flatten(groupedQueries); if (!queries || queries.length === 0) { return Promise.resolve([]); @@ -118,7 +233,7 @@ export default class AzureMonitorDatasource { const result: DataQueryResponseData[] = []; if (data.results) { - Object['values'](data.results).forEach((queryRes: any) => { + Object.values(data.results).forEach((queryRes: any) => { if (!queryRes.series) { return; } @@ -337,12 +452,31 @@ export default class AzureMonitorDatasource { }); } + async getResources(subscriptionIds: string[]): Promise { + const responses: Resource[][] = await Promise.all( + subscriptionIds.map(subscriptionId => + this.doRequest(`${this.baseUrl}/${subscriptionId}/resources?api-version=2018-02-01`).then( + (res: AzureMonitorResourceResponse) => + res.data.value + .map(r => ({ + ...r, + group: /.*\/resourceGroups\/(.*?)\//.exec(r.id)[1], + subscriptionId, + })) + .filter(({ type }) => this.supportedMetricNamespaces.includes(type)) + ) + ) + ); + + return responses.reduce((result, resources) => [...result, ...resources], []); + } + getMetricNames( subscriptionId: string, resourceGroup: string, metricDefinition: string, resourceName: string, - metricNamespace: string + metricNamespace?: string ) { const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( this.baseUrl, @@ -350,8 +484,8 @@ export default class AzureMonitorDatasource { resourceGroup, metricDefinition, resourceName, - metricNamespace, - this.apiVersion + this.apiVersion, + metricNamespace ); return this.doRequest(url).then((result: any) => { @@ -364,8 +498,8 @@ export default class AzureMonitorDatasource { resourceGroup: string, metricDefinition: string, resourceName: string, - metricNamespace: string, - metricName: string + metricName: string, + metricNamespace?: string ) { const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( this.baseUrl, @@ -373,8 +507,8 @@ export default class AzureMonitorDatasource { resourceGroup, metricDefinition, resourceName, - metricNamespace, - this.apiVersion + this.apiVersion, + metricNamespace ); return this.doRequest(url).then((result: any) => { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts index d67b1d74c1b7a..e9f951b752f42 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/response_parser.ts @@ -108,8 +108,8 @@ export default class ResponseParser { return dimensions; } - static parseSubscriptions(result: any): Array<{ text: string; value: string }> { - const list: Array<{ text: string; value: string }> = []; + static parseSubscriptions(result: any): Array<{ text: string; value: string; displayName: string }> { + const list: Array<{ text: string; value: string; displayName: string }> = []; if (!result) { return list; @@ -122,6 +122,7 @@ export default class ResponseParser { list.push({ text: `${_.get(result.data.value[i], textFieldName)} - ${_.get(result.data.value[i], valueFieldName)}`, value: _.get(result.data.value[i], valueFieldName), + displayName: _.get(result.data.value[i], textFieldName), }); } } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts index 0762266dc327c..b4ffd1857d1ca 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.test.ts @@ -9,8 +9,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Sql/servers/databases', 'rn1/rn2', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn1/databases/rn2/' + @@ -27,8 +27,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Sql/servers', 'rn', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Sql/servers/rn/' + @@ -45,8 +45,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Storage/storageAccounts/blobServices', 'rn1/default', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/blobServices/default/' + @@ -63,8 +63,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Storage/storageAccounts/fileServices', 'rn1/default', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/fileServices/default/' + @@ -81,8 +81,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Storage/storageAccounts/tableServices', 'rn1/default', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/tableServices/default/' + @@ -99,8 +99,8 @@ describe('AzureMonitorUrlBuilder', () => { 'rg', 'Microsoft.Storage/storageAccounts/queueServices', 'rn1/default', - 'default', - '2017-05-01-preview' + '2017-05-01-preview', + 'default' ); expect(url).toBe( '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' + @@ -108,4 +108,22 @@ describe('AzureMonitorUrlBuilder', () => { ); }); }); + + describe('when metric namespace is missing', () => { + it('should be excluded from the query', () => { + const url = UrlBuilder.buildAzureMonitorGetMetricNamesUrl( + '', + 'sub1', + 'rg', + 'Microsoft.Storage/storageAccounts/queueServices', + 'rn1/default', + '2017-05-01-preview' + ); + + expect(url).toBe( + '/sub1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/rn1/queueServices/default/' + + 'providers/microsoft.insights/metricdefinitions?api-version=2017-05-01-preview' + ); + }); + }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts index e8cb3afcc1c9d..98ffb9d0c4008 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/url_builder.ts @@ -29,26 +29,24 @@ export default class UrlBuilder { resourceGroup: string, metricDefinition: string, resourceName: string, - metricNamespace: string, - apiVersion: string + apiVersion: string, + metricNamespace?: string ) { + const metricNameSpaceParam = metricNamespace ? `&metricnamespace=${encodeURIComponent(metricNamespace)}` : ''; if ((metricDefinition.match(/\//g) || []).length > 1) { const rn = resourceName.split('/'); const service = metricDefinition.substring(metricDefinition.lastIndexOf('/') + 1); const md = metricDefinition.substring(0, metricDefinition.lastIndexOf('/')); + return ( `${baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/providers/${md}/${rn[0]}/${service}/${rn[1]}` + - `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}&metricnamespace=${encodeURIComponent( - metricNamespace - )}` + `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}${metricNameSpaceParam}` ); } return ( `${baseUrl}/${subscriptionId}/resourceGroups/${resourceGroup}/providers/${metricDefinition}/${resourceName}` + - `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}&metricnamespace=${encodeURIComponent( - metricNamespace - )}` + `/providers/microsoft.insights/metricdefinitions?api-version=${apiVersion}${metricNameSpaceParam}` ); } } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts index ac579bcb3831f..744f906795529 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { migrateTargetSchema } from './migrations'; import AzureMonitorDatasource from './azure_monitor/azure_monitor_datasource'; import AppInsightsDatasource from './app_insights/app_insights_datasource'; import AzureLogAnalyticsDatasource from './azure_log_analytics/azure_log_analytics_datasource'; @@ -42,7 +43,9 @@ export default class Datasource extends DataSourceApi t.queryType === 'Azure Monitor') + .map((t: any) => migrateTargetSchema(t)); appInsightsOptions.targets = _.filter(appInsightsOptions.targets, ['queryType', 'Application Insights']); azureLogAnalyticsOptions.targets = _.filter(azureLogAnalyticsOptions.targets, ['queryType', 'Azure Log Analytics']); @@ -163,7 +166,7 @@ export default class Datasource extends DataSourceApi; + selectedValues: Array<{ text: string; value: string }>; + initialValues: string[]; + onUpdated: any; + + show() { + this.highlightIndex = -1; + this.options = this.options; + this.selectedValues = this.options.filter(({ selected }) => selected); + + this.dropdownVisible = true; + } + + hide() { + this.dropdownVisible = false; + } + + updateLinkText() { + this.linkText = + this.selectedValues.length === 1 ? this.selectedValues[0].text : `(${this.selectedValues.length}) selected`; + } + + clearSelections() { + this.selectedValues = _.filter(this.options, { selected: true }); + + if (this.selectedValues.length > 1) { + _.each(this.options, option => { + option.selected = false; + }); + } else { + _.each(this.options, option => { + option.selected = true; + }); + } + this.selectionsChanged(); + } + + selectValue(option: any) { + if (!option) { + return; + } + + option.selected = !option.selected; + this.selectionsChanged(); + } + + selectionsChanged() { + this.selectedValues = _.filter(this.options, { selected: true }); + if (!this.selectedValues.length && this.options.length) { + this.selectedValues = this.options.slice(0, 1); + } + this.updateLinkText(); + this.onUpdated({ values: this.selectedValues.map(({ value }) => value) }); + } + + onClickOutside() { + this.selectedValues = _.filter(this.options, { selected: true }); + if (this.selectedValues.length === 0) { + this.options[0].selected = true; + this.selectionsChanged(); + } + this.dropdownVisible = false; + } + + init() { + if (!this.options) { + return; + } + + this.options = this.options.map(o => ({ + ...o, + selected: this.initialValues.includes(o.value), + })); + this.selectedValues = _.filter(this.options, { selected: true }); + if (!this.selectedValues.length) { + this.options = this.options.map(o => ({ + ...o, + selected: true, + })); + } + this.updateLinkText(); + } + + updateSelection() { + this.selectedValues = _.filter(this.options, { selected: true }); + if (!this.selectedValues.length && this.options.length) { + this.options = this.options.map(o => ({ + ...o, + selected: true, + })); + this.selectedValues = _.filter(this.options, { selected: true }); + this.selectionsChanged(); + } + this.updateLinkText(); + } +} + +/** @ngInject */ +export function multiSelectDropdown($window: any, $timeout: any) { + return { + scope: { onUpdated: '&', options: '=', initialValues: '=' }, + templateUrl: 'public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/multi-select.directive.html', + controller: MultiSelectDropdownCtrl, + controllerAs: 'vm', + bindToController: true, + link: (scope: any, elem: any) => { + const bodyEl = angular.element($window.document.body); + const linkEl = elem.find('.variable-value-link'); + const inputEl = elem.find('input'); + + function openDropdown() { + inputEl.css('width', Math.max(linkEl.width(), 80) + 'px'); + + inputEl.show(); + linkEl.hide(); + + inputEl.focus(); + $timeout( + () => { + bodyEl.on('click', () => { + bodyEl.on('click', bodyOnClick); + }); + }, + 0, + false + ); + } + + function switchToLink() { + inputEl.hide(); + linkEl.show(); + bodyEl.off('click', bodyOnClick); + } + + function bodyOnClick(e: any) { + if (elem.has(e.target).length === 0) { + scope.$apply(() => { + scope.vm.onClickOutside(); + }); + } + } + + scope.$watch('vm.options', (newValue: any) => { + if (newValue) { + scope.vm.updateSelection(newValue); + } + }); + + scope.$watch('vm.dropdownVisible', (newValue: any) => { + if (newValue) { + openDropdown(); + } else { + switchToLink(); + } + }); + + scope.vm.init(); + }, + }; +} + +angular.module('grafana.directives').directive('multiSelect', multiSelectDropdown); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/multi-select.directive.html b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/multi-select.directive.html new file mode 100644 index 0000000000000..b48afe1ad3610 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/multi-select.directive.html @@ -0,0 +1,24 @@ + + \ No newline at end of file diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html index a5bed66c9df81..5b2d1010ca97c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/partials/query.editor.html @@ -7,70 +7,138 @@ ng-change="ctrl.onQueryTypeChange()"> -
+
+ +
+ +
+
+
+
+
+
+
+
+ get-options="ctrl.getSubscriptions()" on-change="ctrl.onSubscriptionChange()" css-class="min-width-6">
-
-
-
- - - -
-
- - - -
-
- - - -
-
-
-
+
+
+ + +
+
+
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+ + + +
+
+
+
+
-
+
-
- + +
-
+
-
+
+
+
-
-
+
@@ -78,17 +146,17 @@
-
+
-
-
@@ -98,7 +166,7 @@
-
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts index 9c83b5d9312dc..4d440fd9a14cf 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.test.ts @@ -36,11 +36,11 @@ describe('AzureMonitorQueryCtrl', () => { }); it('should set query parts to select', () => { - expect(queryCtrl.target.azureMonitor.resourceGroup).toBe('select'); - expect(queryCtrl.target.azureMonitor.metricDefinition).toBe('select'); - expect(queryCtrl.target.azureMonitor.resourceName).toBe('select'); - expect(queryCtrl.target.azureMonitor.metricNamespace).toBe('select'); - expect(queryCtrl.target.azureMonitor.metricName).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.resourceGroup).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.metricDefinition).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.resourceName).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.metricNamespace).toBe('select'); + expect(queryCtrl.target.azureMonitor.data.singleResource.metricName).toBe('select'); expect(queryCtrl.target.appInsights.groupBy).toBe('none'); }); }); @@ -76,7 +76,7 @@ describe('AzureMonitorQueryCtrl', () => { beforeEach(() => { queryCtrl.target.subscription = 'sub1'; - queryCtrl.target.azureMonitor.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'test'; queryCtrl.datasource.getMetricDefinitions = function(subscriptionId: any, query: any) { expect(subscriptionId).toBe('sub1'); expect(query).toBe('test'); @@ -94,7 +94,7 @@ describe('AzureMonitorQueryCtrl', () => { describe('and resource group has no value', () => { beforeEach(() => { - queryCtrl.target.azureMonitor.resourceGroup = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'select'; }); it('should return without making a call to datasource', () => { @@ -109,8 +109,8 @@ describe('AzureMonitorQueryCtrl', () => { beforeEach(() => { queryCtrl.target.subscription = 'sub1'; - queryCtrl.target.azureMonitor.resourceGroup = 'test'; - queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'Microsoft.Compute/virtualMachines'; queryCtrl.datasource.getResourceNames = function( subscriptionId: any, resourceGroup: any, @@ -133,8 +133,8 @@ describe('AzureMonitorQueryCtrl', () => { describe('and resourceGroup and metricDefinition do not have values', () => { beforeEach(() => { - queryCtrl.target.azureMonitor.resourceGroup = 'select'; - queryCtrl.target.azureMonitor.metricDefinition = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'select'; }); it('should return without making a call to datasource', () => { @@ -149,10 +149,10 @@ describe('AzureMonitorQueryCtrl', () => { beforeEach(() => { queryCtrl.target.subscription = 'sub1'; - queryCtrl.target.azureMonitor.resourceGroup = 'test'; - queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines'; - queryCtrl.target.azureMonitor.resourceName = 'test'; - queryCtrl.target.azureMonitor.metricNamespace = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'Microsoft.Compute/virtualMachines'; + queryCtrl.target.azureMonitor.data.singleResource.resourceName = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricNamespace = 'test'; queryCtrl.datasource.getMetricNames = function( subscriptionId: any, resourceGroup: any, @@ -179,10 +179,10 @@ describe('AzureMonitorQueryCtrl', () => { describe('and resourceGroup, metricDefinition, resourceName and metricNamespace do not have values', () => { beforeEach(() => { - queryCtrl.target.azureMonitor.resourceGroup = 'select'; - queryCtrl.target.azureMonitor.metricDefinition = 'select'; - queryCtrl.target.azureMonitor.resourceName = 'select'; - queryCtrl.target.azureMonitor.metricNamespace = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.resourceName = 'select'; + queryCtrl.target.azureMonitor.data.singleResource.metricNamespace = 'select'; }); it('should return without making a call to datasource', () => { @@ -201,18 +201,18 @@ describe('AzureMonitorQueryCtrl', () => { beforeEach(() => { queryCtrl.target.subscription = 'sub1'; - queryCtrl.target.azureMonitor.resourceGroup = 'test'; - queryCtrl.target.azureMonitor.metricDefinition = 'Microsoft.Compute/virtualMachines'; - queryCtrl.target.azureMonitor.resourceName = 'test'; - queryCtrl.target.azureMonitor.metricNamespace = 'test'; - queryCtrl.target.azureMonitor.metricName = 'Percentage CPU'; + queryCtrl.target.azureMonitor.data.singleResource.resourceGroup = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricDefinition = 'Microsoft.Compute/virtualMachines'; + queryCtrl.target.azureMonitor.data.singleResource.resourceName = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricNamespace = 'test'; + queryCtrl.target.azureMonitor.data.singleResource.metricName = 'Percentage CPU'; queryCtrl.datasource.getMetricMetadata = function( subscription: any, resourceGroup: any, metricDefinition: any, resourceName: any, - metricNamespace: any, - metricName: any + metricName: any, + metricNamespace: any ) { expect(subscription).toBe('sub1'); expect(resourceGroup).toBe('test'); @@ -226,9 +226,9 @@ describe('AzureMonitorQueryCtrl', () => { it('should set the options and default selected value for the Aggregations dropdown', () => { queryCtrl.onMetricNameChange().then(() => { - expect(queryCtrl.target.azureMonitor.aggregation).toBe('Average'); - expect(queryCtrl.target.azureMonitor.aggOptions).toBe(['Average', 'Total']); - expect(queryCtrl.target.azureMonitor.timeGrains).toBe(['PT1M', 'P1D']); + expect(queryCtrl.target.azureMonitor.data.singleResource.aggregation).toBe('Average'); + expect(queryCtrl.target.azureMonitor.data.singleResource.aggOptions).toBe(['Average', 'Total']); + expect(queryCtrl.target.azureMonitor.data.singleResource.timeGrains).toBe(['PT1M', 'P1D']); }); }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts index 0e799d2751865..1c5fe02a27e71 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts @@ -1,21 +1,51 @@ import _ from 'lodash'; import { QueryCtrl } from 'app/plugins/sdk'; -// import './css/query_editor.css'; -import TimegrainConverter from './time_grain_converter'; -import './editor/editor_component'; import kbn from 'app/core/utils/kbn'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { auto } from 'angular'; import { DataFrame } from '@grafana/data'; +import { Resource } from './types'; +import { migrateTargetSchema } from './migrations'; +import TimegrainConverter from './time_grain_converter'; +import './editor/editor_component'; +import './multi-select.directive'; + export interface ResultFormat { text: string; value: string; } +interface AzureMonitor { + resourceGroup: string; + resourceGroups: string[]; + resourceName: string; + metricDefinition: string; + metricNamespace: string; + metricName: string; + dimensionFilter: string; + timeGrain: string; + timeGrainUnit: string; + timeGrains: Option[]; + allowedTimeGrainsMs: number[]; + dimensions: any[]; + dimension: any; + aggregation: string; + aggOptions: string[]; + locations: string[]; + queryMode: string; +} + +interface Option { + value: string; + text: string; + displayName?: string; +} + export class AzureMonitorQueryCtrl extends QueryCtrl { static templateUrl = 'partials/query.editor.html'; + static defaultQueryMode = 'singleResource'; defaultDropdownValue = 'select'; @@ -23,21 +53,10 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { refId: string; queryType: string; subscription: string; + subscriptions: string[]; azureMonitor: { - resourceGroup: string; - resourceName: string; - metricDefinition: string; - metricNamespace: string; - metricName: string; - dimensionFilter: string; - timeGrain: string; - timeGrainUnit: string; - timeGrains: Array<{ text: string; value: string }>; - allowedTimeGrainsMs: number[]; - dimensions: any[]; - dimension: any; - aggregation: string; - aggOptions: string[]; + queryMode: string; + data: { [queryMode: string]: AzureMonitor }; }; azureLogAnalytics: { query: string; @@ -63,14 +82,30 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { defaults = { queryType: 'Azure Monitor', + subscriptions: new Array(), azureMonitor: { - resourceGroup: this.defaultDropdownValue, - metricDefinition: this.defaultDropdownValue, - resourceName: this.defaultDropdownValue, - metricNamespace: this.defaultDropdownValue, - metricName: this.defaultDropdownValue, - dimensionFilter: '*', - timeGrain: 'auto', + queryMode: 'singleResource', + data: { + singleResource: { + resourceGroups: new Array(), + resourceGroup: this.defaultDropdownValue, + metricDefinition: this.defaultDropdownValue, + metricNamespace: this.defaultDropdownValue, + metricName: this.defaultDropdownValue, + resourceName: this.defaultDropdownValue, + dimensionFilter: '*', + timeGrain: 'auto', + }, + crossResource: { + resourceGroups: new Array(), + locations: new Array(), + metricDefinition: this.defaultDropdownValue, + resourceName: this.defaultDropdownValue, + metricName: this.defaultDropdownValue, + dimensionFilter: '*', + timeGrain: 'auto', + }, + }, }, azureLogAnalytics: { query: [ @@ -108,12 +143,17 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { showLastQuery: boolean; lastQuery: string; lastQueryError?: string; - subscriptions: Array<{ text: string; value: string }>; + subscriptions: Option[]; + subscriptionValues: string[]; + resources: Resource[]; + locations: Option[]; + resourceGroups: Option[]; /** @ngInject */ constructor($scope: any, $injector: auto.IInjectorService, private templateSrv: TemplateSrv) { super($scope, $injector); + this.target = migrateTargetSchema(this.target); _.defaultsDeep(this.target, this.defaults); this.migrateTimeGrains(); @@ -125,12 +165,35 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope); this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope); this.resultFormats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }]; - this.getSubscriptions(); + this.resources = new Array(); + this.subscriptionValues = []; + + this.init(); if (this.target.queryType === 'Azure Log Analytics') { this.getWorkspaces(); } } + async init() { + const subscriptions = await this.getSubscriptions(); + this.datasource.getResources(subscriptions.map((s: Option) => s.value)).then(async (resources: Resource[]) => { + if (!this.target.subscriptions.length) { + this.target.subscriptions = this.subscriptions.map(s => s.value); + } + this.resources = resources; + this.updateLocations(); + this.updateCrossResourceGroups(); + }); + } + + updateLocations() { + this.locations = this.getLocations().map(l => ({ text: l, value: l })); + } + + updateCrossResourceGroups() { + this.resourceGroups = this.getCrossResourceGroups().map(rg => ({ text: rg, value: rg })); + } + onDataReceived(dataList: DataFrame[]) { this.lastQueryError = undefined; this.lastQuery = ''; @@ -170,24 +233,28 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { } migrateTimeGrains() { - if (this.target.azureMonitor.timeGrainUnit) { - if (this.target.azureMonitor.timeGrain !== 'auto') { - this.target.azureMonitor.timeGrain = TimegrainConverter.createISO8601Duration( - this.target.azureMonitor.timeGrain, - this.target.azureMonitor.timeGrainUnit + const { queryMode } = this.target.azureMonitor; + if (this.target.azureMonitor.data[queryMode].timeGrainUnit) { + if (this.target.azureMonitor.data[queryMode].timeGrain !== 'auto') { + this.target.azureMonitor.data[queryMode].timeGrain = TimegrainConverter.createISO8601Duration( + this.target.azureMonitor.data[queryMode].timeGrain, + this.target.azureMonitor.data[queryMode].timeGrainUnit ); } - delete this.target.azureMonitor.timeGrainUnit; + delete this.target.azureMonitor.data[queryMode].timeGrainUnit; this.onMetricNameChange(); } if ( - this.target.azureMonitor.timeGrains && - this.target.azureMonitor.timeGrains.length > 0 && - (!this.target.azureMonitor.allowedTimeGrainsMs || this.target.azureMonitor.allowedTimeGrainsMs.length === 0) + this.target.azureMonitor.data[queryMode].timeGrains && + this.target.azureMonitor.data[queryMode].timeGrains.length > 0 && + (!this.target.azureMonitor.data[queryMode].allowedTimeGrainsMs || + this.target.azureMonitor.data[queryMode].allowedTimeGrainsMs.length === 0) ) { - this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(this.target.azureMonitor.timeGrains); + this.target.azureMonitor.data[queryMode].allowedTimeGrainsMs = this.convertTimeGrainsToMs( + this.target.azureMonitor.data[queryMode].timeGrains + ); } } @@ -197,15 +264,18 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { } async migrateToDefaultNamespace() { + const { queryMode } = this.target.azureMonitor; if ( - this.target.azureMonitor.metricNamespace && - this.target.azureMonitor.metricNamespace !== this.defaultDropdownValue && - this.target.azureMonitor.metricDefinition + this.target.azureMonitor.data[queryMode].metricNamespace && + this.target.azureMonitor.data[queryMode].metricNamespace !== this.defaultDropdownValue && + this.target.azureMonitor.data[queryMode].metricDefinition ) { return; } - this.target.azureMonitor.metricNamespace = this.target.azureMonitor.metricDefinition; + this.target.azureMonitor.data[queryMode].metricNamespace = this.target.azureMonitor.data[ + queryMode + ].metricDefinition; } replace(variable: string) { @@ -218,13 +288,19 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { } } - getSubscriptions() { + async getSubscriptions() { if (!this.datasource.azureMonitorDatasource.isConfigured()) { return; } return this.datasource.azureMonitorDatasource.getSubscriptions().then((subs: any) => { this.subscriptions = subs; + this.subscriptionValues = subs.map((s: Option) => ({ value: s.value, text: s.displayName })); + + if (!this.target.subscriptions.length) { + this.target.subscriptions = subs.map((s: Option) => s.value); + } + if (!this.target.subscription && this.target.queryType === 'Azure Monitor') { this.target.subscription = this.datasource.azureMonitorDatasource.subscriptionId; } else if (!this.target.subscription && this.target.queryType === 'Azure Log Analytics') { @@ -244,16 +320,36 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { return this.getWorkspaces(); } + const { queryMode } = this.target.azureMonitor; if (this.target.queryType === 'Azure Monitor') { - this.target.azureMonitor.resourceGroup = this.defaultDropdownValue; - this.target.azureMonitor.metricDefinition = this.defaultDropdownValue; - this.target.azureMonitor.resourceName = this.defaultDropdownValue; - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.aggregation = ''; - this.target.azureMonitor.timeGrains = []; - this.target.azureMonitor.timeGrain = ''; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + this.target.azureMonitor.data[queryMode].resourceGroup = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricDefinition = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].resourceName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + } + } + + async onSubscriptionsChange(values: any) { + if (!_.isEqual(this.target.subscriptions.sort(), values.sort())) { + this.target.subscriptions = values; + this.resources = await this.datasource.getResources(this.target.subscriptions); + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].resourceGroup = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricDefinition = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].resourceName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + this.updateLocations(); + this.updateCrossResourceGroups(); } } @@ -270,29 +366,70 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { .catch(this.handleQueryCtrlError.bind(this)); } + getCrossResourceGroups() { + if (this.target.queryType !== 'Azure Monitor' || !this.datasource.azureMonitorDatasource.isConfigured()) { + return []; + } + + return this.resources + .filter(({ location, subscriptionId }) => { + if (this.target.azureMonitor.data.crossResource.locations.length) { + return ( + this.target.azureMonitor.data.crossResource.locations.includes(location) && + this.target.subscriptions.includes(subscriptionId) + ); + } + return this.target.subscriptions.includes(subscriptionId); + }) + .reduce((options, { group }: Resource) => (options.some(o => o === group) ? options : [...options, group]), []); + } + + async getCrossResourceMetricDefinitions(query: any) { + const { locations, resourceGroups } = this.target.azureMonitor.data.crossResource; + return this.resources + .filter(({ location, group }) => locations.includes(location) && resourceGroups.includes(group)) + .reduce( + (options: Option[], { type }: Resource) => + options.some(o => o.value === type) ? options : [...options, { text: type, value: type }], + [] + ); + } + + getLocations() { + return this.resources + .filter(({ subscriptionId }) => this.target.subscriptions.includes(subscriptionId)) + .reduce( + (options: string[], { location }: Resource) => + options.some(o => o === location) ? options : [...options, location], + [] + ); + } + getMetricDefinitions(query: any) { + const { queryMode } = this.target.azureMonitor; if ( this.target.queryType !== 'Azure Monitor' || - !this.target.azureMonitor.resourceGroup || - this.target.azureMonitor.resourceGroup === this.defaultDropdownValue + !this.target.azureMonitor.data[queryMode].resourceGroup || + this.target.azureMonitor.data[queryMode].resourceGroup === this.defaultDropdownValue ) { return; } return this.datasource .getMetricDefinitions( this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), - this.replace(this.target.azureMonitor.resourceGroup) + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup) ) .catch(this.handleQueryCtrlError.bind(this)); } getResourceNames(query: any) { + const { queryMode } = this.target.azureMonitor; if ( this.target.queryType !== 'Azure Monitor' || - !this.target.azureMonitor.resourceGroup || - this.target.azureMonitor.resourceGroup === this.defaultDropdownValue || - !this.target.azureMonitor.metricDefinition || - this.target.azureMonitor.metricDefinition === this.defaultDropdownValue + !this.target.azureMonitor.data[queryMode].resourceGroup || + this.target.azureMonitor.data[queryMode].resourceGroup === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].metricDefinition || + this.target.azureMonitor.data[queryMode].metricDefinition === this.defaultDropdownValue ) { return; } @@ -300,21 +437,22 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { return this.datasource .getResourceNames( this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), - this.replace(this.target.azureMonitor.resourceGroup), - this.replace(this.target.azureMonitor.metricDefinition) + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup), + this.replace(this.target.azureMonitor.data[queryMode].metricDefinition) ) .catch(this.handleQueryCtrlError.bind(this)); } getMetricNamespaces() { + const { queryMode } = this.target.azureMonitor; if ( this.target.queryType !== 'Azure Monitor' || - !this.target.azureMonitor.resourceGroup || - this.target.azureMonitor.resourceGroup === this.defaultDropdownValue || - !this.target.azureMonitor.metricDefinition || - this.target.azureMonitor.metricDefinition === this.defaultDropdownValue || - !this.target.azureMonitor.resourceName || - this.target.azureMonitor.resourceName === this.defaultDropdownValue + !this.target.azureMonitor.data[queryMode].resourceGroup || + this.target.azureMonitor.data[queryMode].resourceGroup === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].metricDefinition || + this.target.azureMonitor.data[queryMode].metricDefinition === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].resourceName || + this.target.azureMonitor.data[queryMode].resourceName === this.defaultDropdownValue ) { return; } @@ -322,24 +460,50 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { return this.datasource .getMetricNamespaces( this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), - this.replace(this.target.azureMonitor.resourceGroup), - this.replace(this.target.azureMonitor.metricDefinition), - this.replace(this.target.azureMonitor.resourceName) + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup), + this.replace(this.target.azureMonitor.data[queryMode].metricDefinition), + this.replace(this.target.azureMonitor.data[queryMode].resourceName) ) .catch(this.handleQueryCtrlError.bind(this)); } + async getCrossResourceMetricNames() { + const { locations, resourceGroups, metricDefinition } = this.target.azureMonitor.data.crossResource; + + const resources = this.resources.filter( + ({ type, location, name, group }) => + resourceGroups.includes(group) && type === metricDefinition && locations.includes(location) + ); + + const uniqueResources = _.uniqBy(resources, ({ subscriptionId, name, type, group }: Resource) => + [subscriptionId, name, locations, group].join() + ); + + const responses = await Promise.all( + uniqueResources.map(({ subscriptionId, group, type, name }) => + this.datasource + .getMetricNames(subscriptionId, group, type, name) + .then((metrics: any) => metrics.map((m: any) => ({ ...m, subscriptionIds: [subscriptionId] })), [ + { text: this.defaultDropdownValue, value: this.defaultDropdownValue }, + ]) + ) + ); + + return _.uniqBy(responses.reduce((result, resources) => [...result, ...resources], []), ({ value }) => value); + } + getMetricNames() { + const { queryMode } = this.target.azureMonitor; if ( this.target.queryType !== 'Azure Monitor' || - !this.target.azureMonitor.resourceGroup || - this.target.azureMonitor.resourceGroup === this.defaultDropdownValue || - !this.target.azureMonitor.metricDefinition || - this.target.azureMonitor.metricDefinition === this.defaultDropdownValue || - !this.target.azureMonitor.resourceName || - this.target.azureMonitor.resourceName === this.defaultDropdownValue || - !this.target.azureMonitor.metricNamespace || - this.target.azureMonitor.metricNamespace === this.defaultDropdownValue + !this.target.azureMonitor.data[queryMode].resourceGroup || + this.target.azureMonitor.data[queryMode].resourceGroup === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].metricDefinition || + this.target.azureMonitor.data[queryMode].metricDefinition === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].resourceName || + this.target.azureMonitor.data[queryMode].resourceName === this.defaultDropdownValue || + !this.target.azureMonitor.data[queryMode].metricNamespace || + this.target.azureMonitor.data[queryMode].metricNamespace === this.defaultDropdownValue ) { return; } @@ -347,87 +511,168 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { return this.datasource .getMetricNames( this.replace(this.target.subscription || this.datasource.azureMonitorDatasource.subscriptionId), - this.replace(this.target.azureMonitor.resourceGroup), - this.replace(this.target.azureMonitor.metricDefinition), - this.replace(this.target.azureMonitor.resourceName), - this.replace(this.target.azureMonitor.metricNamespace) + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup), + this.replace(this.target.azureMonitor.data[queryMode].metricDefinition), + this.replace(this.target.azureMonitor.data[queryMode].resourceName), + this.replace(this.target.azureMonitor.data[queryMode].metricNamespace) ) .catch(this.handleQueryCtrlError.bind(this)); } onResourceGroupChange() { - this.target.azureMonitor.metricDefinition = this.defaultDropdownValue; - this.target.azureMonitor.resourceName = this.defaultDropdownValue; - this.target.azureMonitor.metricNamespace = this.defaultDropdownValue; - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.aggregation = ''; - this.target.azureMonitor.timeGrains = []; - this.target.azureMonitor.timeGrain = ''; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricDefinition = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].resourceName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricNamespace = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; this.refresh(); } + onCrossResourceGroupChange(values: string[]) { + if (!_.isEqual(this.target.azureMonitor.data.crossResource.resourceGroups.sort(), values.sort())) { + this.target.azureMonitor.data.crossResource.resourceGroups = values; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricDefinition = ''; + this.target.azureMonitor.data[queryMode].metricName = ''; + this.refresh(); + } + } + + onCrossResourceMetricDefinitionChange() { + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + this.refresh(); + } + + async onLocationsChange(values: string[]) { + if (!_.isEqual(this.target.azureMonitor.data.crossResource.locations.sort(), values.sort())) { + this.target.azureMonitor.data.crossResource.locations = values; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricDefinition = ''; + this.target.azureMonitor.data[queryMode].resourceGroup = ''; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + this.updateCrossResourceGroups(); + this.refresh(); + } + } + onMetricDefinitionChange() { - this.target.azureMonitor.resourceName = this.defaultDropdownValue; - this.target.azureMonitor.metricNamespace = this.defaultDropdownValue; - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.aggregation = ''; - this.target.azureMonitor.timeGrains = []; - this.target.azureMonitor.timeGrain = ''; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].resourceName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricNamespace = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; } onResourceNameChange() { - this.target.azureMonitor.metricNamespace = this.defaultDropdownValue; - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.aggregation = ''; - this.target.azureMonitor.timeGrains = []; - this.target.azureMonitor.timeGrain = ''; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricNamespace = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].aggregation = ''; + this.target.azureMonitor.data[queryMode].timeGrains = []; + this.target.azureMonitor.data[queryMode].timeGrain = ''; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; this.refresh(); } onMetricNamespacesChange() { - this.target.azureMonitor.metricName = this.defaultDropdownValue; - this.target.azureMonitor.dimensions = []; - this.target.azureMonitor.dimension = ''; + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].metricName = this.defaultDropdownValue; + this.target.azureMonitor.data[queryMode].dimensions = []; + this.target.azureMonitor.data[queryMode].dimension = ''; + } + + setMetricMetadata(metadata: any) { + const { queryMode } = this.target.azureMonitor; + this.target.azureMonitor.data[queryMode].aggOptions = metadata.supportedAggTypes || [metadata.primaryAggType]; + this.target.azureMonitor.data[queryMode].aggregation = metadata.primaryAggType; + this.target.azureMonitor.data[queryMode].timeGrains = [{ text: 'auto', value: 'auto' }].concat( + metadata.supportedTimeGrains + ); + this.target.azureMonitor.data[queryMode].timeGrain = 'auto'; + + this.target.azureMonitor.data[queryMode].allowedTimeGrainsMs = this.convertTimeGrainsToMs( + metadata.supportedTimeGrains || [] + ); + + this.target.azureMonitor.data[queryMode].dimensions = metadata.dimensions; + if (metadata.dimensions.length > 0) { + this.target.azureMonitor.data[queryMode].dimension = metadata.dimensions[0].value; + } + return this.refresh(); } - onMetricNameChange() { - if (!this.target.azureMonitor.metricName || this.target.azureMonitor.metricName === this.defaultDropdownValue) { + onCrossResourceMetricNameChange() { + const { queryMode } = this.target.azureMonitor; + if ( + !this.target.azureMonitor.data[queryMode].metricName || + this.target.azureMonitor.data[queryMode].metricName === this.defaultDropdownValue + ) { return; } + const { resourceGroups, metricDefinition, metricName } = this.target.azureMonitor.data[queryMode]; + + const resource = this.resources.find( + ({ type, group }) => type === metricDefinition && resourceGroups.includes(group) + ); + return this.datasource .getMetricMetadata( - this.replace(this.target.subscription), - this.replace(this.target.azureMonitor.resourceGroup), - this.replace(this.target.azureMonitor.metricDefinition), - this.replace(this.target.azureMonitor.resourceName), - this.replace(this.target.azureMonitor.metricNamespace), - this.replace(this.target.azureMonitor.metricName) + this.replace(this.target.subscriptions[0]), + resource.group, + metricDefinition, + resource.name, + metricName ) - .then((metadata: any) => { - this.target.azureMonitor.aggOptions = metadata.supportedAggTypes || [metadata.primaryAggType]; - this.target.azureMonitor.aggregation = metadata.primaryAggType; - this.target.azureMonitor.timeGrains = [{ text: 'auto', value: 'auto' }].concat(metadata.supportedTimeGrains); - this.target.azureMonitor.timeGrain = 'auto'; + .then(this.setMetricMetadata.bind(this)) + .then(() => this.refresh()) + .catch(this.handleQueryCtrlError.bind(this)); + } - this.target.azureMonitor.allowedTimeGrainsMs = this.convertTimeGrainsToMs(metadata.supportedTimeGrains || []); + onMetricNameChange() { + const { queryMode } = this.target.azureMonitor; + if ( + !this.target.azureMonitor.data[queryMode].metricName || + this.target.azureMonitor.data[queryMode].metricName === this.defaultDropdownValue + ) { + return; + } - this.target.azureMonitor.dimensions = metadata.dimensions; - if (metadata.dimensions.length > 0) { - this.target.azureMonitor.dimension = metadata.dimensions[0].value; - } - return this.refresh(); - }) + return this.datasource + .getMetricMetadata( + this.replace(this.target.subscription), + this.replace(this.target.azureMonitor.data[queryMode].resourceGroup), + this.replace(this.target.azureMonitor.data[queryMode].metricDefinition), + this.replace(this.target.azureMonitor.data[queryMode].resourceName), + this.replace(this.target.azureMonitor.data[queryMode].metricName), + this.replace(this.target.azureMonitor.data[queryMode].metricNamespace) + ) + .then(this.setMetricMetadata.bind(this)) .catch(this.handleQueryCtrlError.bind(this)); } - convertTimeGrainsToMs(timeGrains: Array<{ text: string; value: string }>) { + convertTimeGrainsToMs(timeGrains: Option[]) { const allowedTimeGrainsMs: number[] = []; timeGrains.forEach((tg: any) => { if (tg.value !== 'auto') { @@ -438,10 +683,11 @@ export class AzureMonitorQueryCtrl extends QueryCtrl { } getAutoInterval() { - if (this.target.azureMonitor.timeGrain === 'auto') { + const { queryMode } = this.target.azureMonitor; + if (this.target.azureMonitor.data[queryMode].timeGrain === 'auto') { return TimegrainConverter.findClosestTimeGrain( this.templateSrv.getBuiltInIntervalValue(), - _.map(this.target.azureMonitor.timeGrains, o => + _.map(this.target.azureMonitor.data[queryMode].timeGrains, o => TimegrainConverter.createKbnUnitFromISO8601Duration(o.value) ) || ['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d'] ); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts index 7be4bad2c86dd..7fe0134196962 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/types.ts @@ -3,6 +3,7 @@ import { DataQuery, DataSourceJsonData } from '@grafana/ui'; export interface AzureMonitorQuery extends DataQuery { format: string; subscription: string; + subscriptions: string[]; azureMonitor: AzureMetricQuery; azureLogAnalytics: AzureLogsQuery; // appInsights: any; @@ -26,9 +27,9 @@ export interface AzureDataSourceJsonData extends DataSourceJsonData { // App Insights appInsightsAppId?: string; } - -export interface AzureMetricQuery { +export interface AzureMonitorQueryData { resourceGroup: string; + resourceGroups: string[]; resourceName: string; metricDefinition: string; metricNamespace: string; @@ -41,6 +42,12 @@ export interface AzureMetricQuery { dimension: string; dimensionFilter: string; alias: string; + locations: string[]; +} + +export interface AzureMetricQuery extends AzureMonitorQueryData { + queryMode: string; + data: { [queryMode: string]: AzureMonitorQueryData }; } export interface AzureLogsQuery { @@ -67,6 +74,24 @@ export interface AzureMonitorResourceGroupsResponse { statusText: string; } +export interface Resource { + id: string; + name: string; + type: string; + location: string; + kind: string; + subscriptionId: string; + group: string; +} + +export interface AzureMonitorResourceResponse { + data: { + value: Resource[]; + status: number; + statusText: string; + }; +} + // Azure Log Analytics types export interface KustoSchema { Databases: { [key: string]: KustoDatabase }; diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 242039980465b..bc98a80a16dbd 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -55,6 +55,7 @@ export class PrometheusDatasource extends DataSourceApi httpMethod: string; languageProvider: PrometheusLanguageProvider; resultTransformer: ResultTransformer; + customQueryParameters: any; /** @ngInject */ constructor( @@ -78,6 +79,7 @@ export class PrometheusDatasource extends DataSourceApi this.resultTransformer = new ResultTransformer(templateSrv); this.ruleMappings = {}; this.languageProvider = new PrometheusLanguageProvider(this); + this.customQueryParameters = new URLSearchParams(instanceSettings.jsonData.customQueryParameters); } init = () => { @@ -348,6 +350,12 @@ export class PrometheusDatasource extends DataSourceApi data['timeout'] = this.queryTimeout; } + for (const [key, value] of this.customQueryParameters) { + if (data[key] == null) { + data[key] = value; + } + } + return this._request(url, data, { requestId: query.requestId, headers: query.headers }).catch((err: any) => { if (err.cancelled) { return err; @@ -368,6 +376,12 @@ export class PrometheusDatasource extends DataSourceApi data['timeout'] = this.queryTimeout; } + for (const [key, value] of this.customQueryParameters) { + if (data[key] == null) { + data[key] = value; + } + } + return this._request(url, data, { requestId: query.requestId, headers: query.headers }).catch((err: any) => { if (err.cancelled) { return err; diff --git a/public/app/plugins/datasource/prometheus/partials/config.html b/public/app/plugins/datasource/prometheus/partials/config.html index c0ee73719fefc..1f050cafd1388 100644 --- a/public/app/plugins/datasource/prometheus/partials/config.html +++ b/public/app/plugins/datasource/prometheus/partials/config.html @@ -48,3 +48,23 @@
+

Misc

+
+
+
+ Custom query parameters + + + Add Custom parameters to Prometheus or Thanos queries. + +
+
+
+ + diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts index 387b9c9e3d0bd..fba388bf1c2b3 100644 --- a/public/app/plugins/datasource/prometheus/types.ts +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -25,6 +25,7 @@ export interface PromOptions extends DataSourceJsonData { queryTimeout: string; httpMethod: string; directUrl: string; + customQueryParameters?: string; } export interface PromQueryRequest extends PromQuery { diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 1c7f7821f2e43..b21243984bc3a 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -1,7 +1,6 @@ import './dashboard_loaders'; import './ReactContainer'; import { applyRouteRegistrationHandlers } from './registry'; - // Pages import CreateFolderCtrl from 'app/features/folders/CreateFolderCtrl'; import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl'; @@ -10,10 +9,10 @@ import LdapPage from 'app/features/admin/ldap/LdapPage'; import LdapUserPage from 'app/features/admin/ldap/LdapUserPage'; import config from 'app/core/config'; import { route, ILocationProvider } from 'angular'; - // Types import { DashboardRouteInfo } from 'app/types'; import { LoginPage } from 'app/core/components/Login/LoginPage'; +import { SafeDynamicImport } from '../core/components/SafeDynamicImport'; /** @ngInject */ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locationProvider: ILocationProvider) { @@ -23,7 +22,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati // ones. That means angular ones could be navigated to in case there is a client side link some where. const importDashboardPage = () => - import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage'); + SafeDynamicImport(import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPage')); $routeProvider .when('/', { @@ -79,7 +78,9 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati reloadOnSearch: false, resolve: { component: () => - import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage'), + SafeDynamicImport( + import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') + ), }, }) .when('/dashboard-solo/:type/:slug', { @@ -89,7 +90,9 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati reloadOnSearch: false, resolve: { component: () => - import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage'), + SafeDynamicImport( + import(/* webpackChunkName: "SoloPanelPage" */ '../features/dashboard/containers/SoloPanelPage') + ), }, }) .when('/dashboard/import', { @@ -101,7 +104,9 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', resolve: { component: () => - import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage'), + SafeDynamicImport( + import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage') + ), }, }) .when('/datasources/edit/:id/', { @@ -109,20 +114,27 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati reloadOnSearch: false, // for tabs resolve: { component: () => - import(/* webpackChunkName: "DataSourceSettingsPage"*/ '../features/datasources/settings/DataSourceSettingsPage'), + SafeDynamicImport( + import(/* webpackChunkName: "DataSourceSettingsPage"*/ '../features/datasources/settings/DataSourceSettingsPage') + ), }, }) .when('/datasources/edit/:id/dashboards', { template: '', resolve: { component: () => - import(/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/DataSourceDashboards'), + SafeDynamicImport( + import(/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/DataSourceDashboards') + ), }, }) .when('/datasources/new', { template: '', resolve: { - component: () => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/NewDataSourcePage'), + component: () => + SafeDynamicImport( + import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/NewDataSourcePage') + ), }, }) .when('/dashboards', { @@ -138,13 +150,19 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/dashboards/f/:uid/:slug/permissions', { template: '', resolve: { - component: () => import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/FolderPermissions'), + component: () => + SafeDynamicImport( + import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/FolderPermissions') + ), }, }) .when('/dashboards/f/:uid/:slug/settings', { template: '', resolve: { - component: () => import(/* webpackChunkName: "FolderSettingsPage"*/ 'app/features/folders/FolderSettingsPage'), + component: () => + SafeDynamicImport( + import(/* webpackChunkName: "FolderSettingsPage"*/ 'app/features/folders/FolderSettingsPage') + ), }, }) .when('/dashboards/f/:uid/:slug', { @@ -162,7 +180,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati reloadOnSearch: false, resolve: { roles: () => (config.viewersCanEdit ? [] : ['Editor', 'Admin']), - component: () => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper'), + component: () => SafeDynamicImport(import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper')), }, }) .when('/a/:pluginId/', { @@ -170,13 +188,15 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', reloadOnSearch: false, resolve: { - component: () => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/AppRootPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/AppRootPage')), }, }) .when('/org', { template: '', resolve: { - component: () => import(/* webpackChunkName: "OrgDetailsPage" */ '../features/org/OrgDetailsPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "OrgDetailsPage" */ '../features/org/OrgDetailsPage')), }, }) .when('/org/new', { @@ -186,7 +206,8 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/org/users', { template: '', resolve: { - component: () => import(/* webpackChunkName: "UsersListPage" */ 'app/features/users/UsersListPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "UsersListPage" */ 'app/features/users/UsersListPage')), }, }) .when('/org/users/invite', { @@ -198,14 +219,15 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', resolve: { roles: () => ['Editor', 'Admin'], - component: () => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage')), }, }) .when('/org/teams', { template: '', resolve: { roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']), - component: () => import(/* webpackChunkName: "TeamList" */ 'app/features/teams/TeamList'), + component: () => SafeDynamicImport(import(/* webpackChunkName: "TeamList" */ 'app/features/teams/TeamList')), }, }) .when('/org/teams/new', { @@ -217,7 +239,7 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', resolve: { roles: () => (config.editorsCanAdmin ? [] : ['Admin']), - component: () => import(/* webpackChunkName: "TeamPages" */ 'app/features/teams/TeamPages'), + component: () => SafeDynamicImport(import(/* webpackChunkName: "TeamPages" */ 'app/features/teams/TeamPages')), }, }) .when('/profile', { @@ -228,7 +250,10 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/profile/password', { template: '', resolve: { - component: () => import(/* webPackChunkName: "ChangePasswordPage" */ 'app/features/profile/ChangePasswordPage'), + component: () => + SafeDynamicImport( + import(/* webPackChunkName: "ChangePasswordPage" */ 'app/features/profile/ChangePasswordPage') + ), }, }) .when('/profile/select-org', { @@ -278,7 +303,8 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/admin/stats', { template: '', resolve: { - component: () => import(/* webpackChunkName: "ServerStats" */ 'app/features/admin/ServerStats'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "ServerStats" */ 'app/features/admin/ServerStats')), }, }) .when('/admin/ldap', { @@ -323,14 +349,16 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati .when('/plugins', { template: '', resolve: { - component: () => import(/* webpackChunkName: "PluginListPage" */ 'app/features/plugins/PluginListPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "PluginListPage" */ 'app/features/plugins/PluginListPage')), }, }) .when('/plugins/:pluginId/', { template: '', reloadOnSearch: false, // tabs from query parameters resolve: { - component: () => import(/* webpackChunkName: "PluginPage" */ '../features/plugins/PluginPage'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "PluginPage" */ '../features/plugins/PluginPage')), }, }) .when('/plugins/:pluginId/page/:slug', { @@ -350,7 +378,8 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati template: '', reloadOnSearch: false, resolve: { - component: () => import(/* webpackChunkName: "AlertRuleList" */ 'app/features/alerting/AlertRuleList'), + component: () => + SafeDynamicImport(import(/* webpackChunkName: "AlertRuleList" */ 'app/features/alerting/AlertRuleList')), }, }) .when('/alerting/notifications', { diff --git a/public/sass/_variables.generated.scss b/public/sass/_variables.generated.scss index 09f87e7d8ecbc..87b7d4c313dc5 100644 --- a/public/sass/_variables.generated.scss +++ b/public/sass/_variables.generated.scss @@ -196,6 +196,7 @@ $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default; // sidemenu $side-menu-width: 60px; +$navbar-padding: 20px; // dashboard $dashboard-padding: $space-md; diff --git a/public/sass/components/_footer.scss b/public/sass/components/_footer.scss index 3579171b2bbe3..a2c5ef60ce8d0 100644 --- a/public/sass/components/_footer.scss +++ b/public/sass/components/_footer.scss @@ -49,6 +49,7 @@ // Keeping footer inside the graphic on Login screen .login-page { .footer { + display: block; bottom: $spacer; position: absolute; padding: $space-md 0 $space-md 0; diff --git a/public/sass/components/_navbar.scss b/public/sass/components/_navbar.scss index b174129173df3..ebabea49c8fb3 100644 --- a/public/sass/components/_navbar.scss +++ b/public/sass/components/_navbar.scss @@ -2,7 +2,7 @@ position: relative; z-index: $zindex-navbar-fixed; height: $navbarHeight; - padding: 0 20px 0 50px; + padding: 0 20px 0 60px; display: flex; flex-grow: 1; border-bottom: 1px solid transparent; @@ -44,9 +44,10 @@ } } -.panel-in-fullscreen { +.panel-in-fullscreen, +.panel-in-fullscreen.view-mode--tv { .navbar { - padding-left: 20px; + padding-left: $navbar-padding; } .navbar-button--add-panel, diff --git a/public/sass/components/_view_states.scss b/public/sass/components/_view_states.scss index fbc50cfca3c8e..69e5ae4c93b47 100644 --- a/public/sass/components/_view_states.scss +++ b/public/sass/components/_view_states.scss @@ -9,10 +9,12 @@ } .navbar-page-btn { - transform: translate3d(-36px, 0, 0); - i { - opacity: 0; + display: none; + } + + i.navbar-page-btn__folder-icon { + opacity: inherit; } } @@ -25,6 +27,17 @@ @extend .view-mode--inactive; } +// https://github.com/grafana/grafana/issues/18114 +.view-mode--tv.panel-in-fullscreen { + .navbar { + padding-left: $navbar-padding; + } + + .navbar-page-btn { + transform: none; + } +} + .view-mode--tv { @extend .view-mode--inactive; @@ -34,9 +47,16 @@ box-shadow: none; .sidemenu__top, - .sidemenu__bottom { + .sidemenu__bottom, + .sidemenu__logo_small_breakpoint { display: none; } + + .sidemenu__logo { + @include media-breakpoint-down(sm) { + display: block; + } + } } .navbar { diff --git a/public/sass/layout/_page.scss b/public/sass/layout/_page.scss index 6b2fa95730e0a..66b6c47356a19 100644 --- a/public/sass/layout/_page.scss +++ b/public/sass/layout/_page.scss @@ -99,7 +99,7 @@ .page-heading { font-size: $font-size-h4; margin-top: 0; - margin-bottom: $spacer * 0.7; + margin-bottom: $spacer; } .page-action-bar { diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index df924a14bde86..df8c8b1d96c61 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -6,10 +6,6 @@ margin-left: 0.25em; } -.run-icon { - transform: rotate(90deg); -} - .datasource-picker { .ds-picker { min-width: 200px; diff --git a/scripts/ci-frontend-metrics.sh b/scripts/ci-frontend-metrics.sh index 2db4f7abe0261..5a4219dcfe5e9 100755 --- a/scripts/ci-frontend-metrics.sh +++ b/scripts/ci-frontend-metrics.sh @@ -7,7 +7,7 @@ ERROR_COUNT_LIMIT=1549 DIRECTIVES_LIMIT=172 CONTROLLERS_LIMIT=139 -ERROR_COUNT="$(./node_modules/.bin/tsc --project tsconfig.json --noEmit --strict true | grep -oP 'Found \K(\d+)')" +ERROR_COUNT="$(./node_modules/.bin/tsc --project tsconfig.json --noEmit --strictNullChecks true | grep -oP 'Found \K(\d+)')" DIRECTIVES="$(grep -r -o directive public/app/**/* | wc -l)" CONTROLLERS="$(grep -r -oP 'class .*Ctrl' public/app/**/* | wc -l)" diff --git a/yarn.lock b/yarn.lock index 4f0afd752ad96..3493e5c28db65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3048,6 +3048,13 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/hoist-non-react-statics@3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#a59c0c995cc885bef1b8ec2241b114f9b35b517b" + integrity sha512-O2OGyW9wlO2bbDmZRH17MecArQfsIa1g//ve2IJk6BnmwEglFz5kdhP1BlgeqjVNH5IHIhsc83DWFo8StCe8+Q== + dependencies: + "@types/react" "*" + "@types/hoist-non-react-statics@^3.3.0": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" @@ -9246,16 +9253,16 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^2.3.1: - version "2.5.5" - resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" - -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@3.3.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" dependencies: react-is "^16.7.0" +hoist-non-react-statics@^2.3.1: + version "2.5.5" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" + homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
LDAP GroupOrganisation TeamLDAP
- {team.orgName || ( -
- No match - -
- -
-
-
- )} -
{team.teamName}{team.groupDN}{team.groupDN} + +
+ No match + + + + + +
+
{team.orgName}{team.teamName}