Compare commits

...

57 Commits

Author SHA1 Message Date
Leonard Gram
86c5fe3746 version 6.7.6 (#32122) 2021-03-18 16:39:02 +01:00
Arve Knudsen
3ca75366b4 package.json: Add grafana metadata section (#29915)
* package.json: Add grafana metadata section

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* Fix spelling

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>
2020-12-17 18:16:29 +01:00
bergquist
2718859d69 fixes saml vulnerability
Signed-off-by: bergquist <carl.bergquist@gmail.com>
2020-12-17 13:18:58 +01:00
Arve Knudsen
4277e4d177 Chore: Upgrade Ubuntu to 20.04 (#29822)
Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>
2020-12-14 22:01:46 +01:00
Marcus Efraimsson
8e44bbc5f5 Release v6.7.4 2020-05-26 19:35:38 +02:00
Marcus Efraimsson
7031b922c6 Only allow 32 hexadecimal digits for the avatar hash 2020-05-26 19:35:38 +02:00
Arve Knudsen
a04ef6cefc 6.7.3 cherry-picks (#23808)
* AuthProxy: Fixes bug where long username could not be cached (#22926)

(cherry picked from commit 6c9d833602)

* Server: Exit with 0 if no error (#23312)

Make grafana-server exit with 0 if no error occurred.

(cherry picked from commit 5645d74cbc)

* Dashboard: Save json should preserve folderId (#23314)

(cherry picked from commit 7e3b43eabb)

* TimeSrv: Try to parse 8 and 15 digit numbers as timestamps if parsing as date fails (#21694)

* Try to parse 8 and 15 digit numbers as timestamps if parsing as date fails

Fixes #19738

* Add tests

(cherry picked from commit c89ad9b038)

* BackendSrv: include credentials when withCredentials option is set (#23380)

The fetch() API won't send cookies or other type of credentials unless
you set the credentials init option. Some datasources like Prometheus
and Elasticsearch have `withCredentials` option in Browser access mode,
but this option is not currently getting passed in the fetch() API.

Fixes #23338.

(cherry picked from commit afd8ffde69)

* Dashlist: Fixed dashlist broken in edit mode (#23426)

(cherry picked from commit 363bf7506d)

* Admin: Fix Synced via LDAP message for non-LDAP external users (#23477)

* UserAdmin: remove Synced via LDAP message for non-LDAP users

* UserAdmin: show "Synced via <provider>" message for external users

(cherry picked from commit 4d81cec34f)

* Graphite: Fixed cannot read finally of undefiend (#23512)

(cherry picked from commit 61460ea3a2)

* Hangouts: fixes notifications for alerts with empty message (#23559)

* Hangouts: fixes notifications for alerts with empty message

* Update pkg/services/alerting/notifiers/googlechat.go

Co-Authored-By: Marcus Efraimsson <marcus.efraimsson@gmail.com>

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
(cherry picked from commit 2661054fe8)

* Variables: fixes error when setting adhoc variables values (#23580)

(cherry picked from commit 0091885b13)

* Release 6.7.3

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* ci-metrics-publisher.sh: Fix linting issue

Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>

* TablePanel: Fix XSS issue in header column rename (backport) (#23814)

* escaping html when rendering table header alias.

* fixed tooltip.

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* Security: Fix annotation popup XSS vulnerability (#23813)

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
(cherry picked from commit 3955e8cbad)

Co-authored-by: Jon McKenzie <jcmcken@gmail.com>
Co-authored-by: Peter Holmberg <peterholmberg@users.noreply.github.com>
Co-authored-by: Jesse Tan <jessetan@users.noreply.github.com>
Co-authored-by: Tuan Anh Hoang-Vu <hvtuananh@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>
Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
2020-04-23 12:12:53 +02:00
Sofia Papagiannaki
c5ea64c2c2 Fix CI for pushing a multi-architecture manifest (#23327) 2020-04-03 17:20:45 +03:00
sam
423a25fc32 AzureMonitor: Fix Log Analytics and Application Insights for Azure China (#21803) (#22753)
Something funky going on with GitHub - the build has passed.
2020-04-02 10:02:56 +02:00
Torkel Ödegaard
86241d8bff Revert "grafana/data: PanelTypeChangedHandler API update to use PanelModel instead of panel options object [BREAKING] (#22754)"
This reverts commit 16f3fe7e15.
2020-04-02 10:01:59 +02:00
Torkel Ödegaard
670ee15dbd Bumped version 2020-04-01 18:29:54 +02:00
Torkel Ödegaard
9abfbf18e0 Snapshots: Sanitize orignal url (#23254)
(cherry picked from commit fb114a7524)
2020-04-01 18:28:50 +02:00
Torkel Ödegaard
882ed637c1 Plugins: Expose promiseToDigest (#23249)
(cherry picked from commit ccb8187ccd)
2020-04-01 18:28:49 +02:00
Torkel Ödegaard
676972e798 Variables: Do not update variable from url when value is the same (#23220)
(cherry picked from commit 49d2910e39)
2020-04-01 18:28:49 +02:00
Dominik Prokop
3be4685589 DashboardSave: Add new dashboard check (#23104)
(cherry picked from commit 046d9c1af4)
2020-04-01 18:28:49 +02:00
Torkel Ödegaard
aa227c5c20 Fix: reverted back to import * as module instead of using namespaces (#23069)
* Removed namespace declaration to prevent issues with external plugins.

* fixed imports and tests.

(cherry picked from commit f75387bd14)
2020-04-01 18:27:46 +02:00
Torkel Ödegaard
ba26ac343b BackendSrv: Adds config to response to fix external plugins that use this (#23032)
* BackendSrv: Added config to response

* QueryInspector: Removing config from showing up

* Replace config with request and make it be the unmodified params sent in

(cherry picked from commit 40d195e4a7)
2020-04-01 18:20:47 +02:00
Marcus Andersson
27a8112e06 DataLinks: make sure we use the correct datapoint when dataset contains null value. (#22981)
* Fix to make sure we have the correct dataIndex when using data links.

* fixed strict null errors.

* decreased number of errors.

(cherry picked from commit 41bc1aa3ae)
2020-04-01 18:20:36 +02:00
Steven Vachon
a0a9ca220c Fix mysterious Babel plugin errors (#22974)
(cherry picked from commit d8b346f441)
2020-04-01 18:18:51 +02:00
Torkel Ödegaard
0287819e36 Select: Fixed select text positition (#22952)
(cherry picked from commit 89203136ec)
2020-04-01 17:55:18 +02:00
Dominik Prokop
16f3fe7e15 grafana/data: PanelTypeChangedHandler API update to use PanelModel instead of panel options object [BREAKING] (#22754)
This changes PanelModel's API to support PanelModel API updates when changing panel type. Primary useful when changing panel type between Angular and React panels, as other migrations can be handled via DashboardMigrator.

API change: https://github.com/grafana/grafana/pull/22754/files#diff-d9e3f91dc7d5697f6d85ada008003b4b

(cherry picked from commit 1256d9e78d)
2020-04-01 17:55:17 +02:00
Robby Milo
ca6d08d5cb Docs: Fix Broken Link (#22894)
(cherry picked from commit a61af9ed1d)
2020-03-20 14:13:09 +01:00
Leonard Gram
d01bdb517d release 6.7.1 2020-03-20 14:13:09 +01:00
Torkel Ödegaard
63dfdb7066 Panels: Fixed size issue with panels when existing panel edit mode (#22912)
(cherry picked from commit 8e131384e3)
2020-03-20 14:13:09 +01:00
Torkel Ödegaard
e95667fffb Azure: Fixed dropdowns not showing current value (#22914)
(cherry picked from commit d16211b782)
2020-03-20 14:13:09 +01:00
Hugo Häggmark
c08b901664 BackendSrv: only add content-type on POST, PUT requests (#22910)
* BackendSrv: only add content-type on POST, PUT requests
Fixes #22869

* Tests: imports polyfill for Headers

(cherry picked from commit 8d5c6053db)
2020-03-20 14:13:09 +01:00
Cyril Tovena
7cd6fef466 Check if the datasource is of type loki using meta.id instead of name. (#22877)
Signed-off-by: Cyril Tovena <cyril.tovena@gmail.com>
(cherry picked from commit ec9167e972)
2020-03-20 14:13:09 +01:00
Arve Knudsen
1b4f93b88c CircleCI: Pin grabpl to 0.1.0 (#22904) 2020-03-19 19:06:28 +01:00
Arve Knudsen
c4656a885d Release version 6.7.0
Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com>
2020-03-19 12:27:02 +01:00
Ivana Huckova
818a2f3d64 Design tweaks (#22886)
(cherry picked from commit 8ba75e77b1)
2020-03-19 12:27:02 +01:00
Jess
7f52e023b5 Rich history UX fixes (#22783)
* Initial commit

* Visualised renamed or deleted  datasources as well, if they have queries

* Pass ds image to card and information if the datasource was removed/renamed

* Set up card with datasource info and change run query

* Style comment, run button

* Fix button naming

* Remember last filters

* Update public/app/core/store.ts

* Update public/app/features/explore/RichHistory/RichHistory.tsx

* Update comments

* Rename datasource to data source

* Add test coverage, fix naming

* Remove unused styles, add feedback info

Co-authored-by: Ivana <ivana.huckova@gmail.com>
Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
(cherry picked from commit db85c3e7b9)
2020-03-19 12:27:02 +01:00
Daniel Lee
962a06545a AzureMonitor: support workspaces function for template variables (#22882)
* azuremonitor: adds support for workspaces query macro...

...for Azure Logs template variable queries

* docs: azure logs workspaces templating function

* Update docs/sources/features/datasources/azuremonitor.md

Co-Authored-By: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>

* docs: convert list into table

* docs: fixes prettier formatting problem

Prettier adds a slash before dollar signs in markdown. Disabling it
for this table with a prettier comment.

https://prettier.io/docs/en/ignore.html

Co-authored-by: Diana Payton <52059945+oddlittlebird@users.noreply.github.com>
(cherry picked from commit 3b9a4e6444)
2020-03-19 12:27:02 +01:00
Arve Knudsen
79aeeaa10a SQLStore: Add migration for adding index on annotation.alert_id (#22876)
(cherry picked from commit bb05989e43)
2020-03-19 12:27:02 +01:00
Carl Bergquist
ea483c0ce1 Plugins: Return jsondetails as an json object instead of raw json on datasource healthchecks. (#22859)
(cherry picked from commit 579abad9cc)
2020-03-19 12:27:02 +01:00
Marcus Efraimsson
3e88197f96 Backend plugins: Exclude plugin metrics in Grafana's metrics endpoint (#22857)
Excludes backend plugin metrics in Grafana's metrics endpoint
Adds /api/:pluginId/metrics endpoint for retrieving metrics
from backend plugin as Prometheus text-based exposition format.

Fixes #22814

(cherry picked from commit 60e3437fc1)
2020-03-19 12:27:02 +01:00
Torkel Ödegaard
66df54db80 Graphite: Fixed issue with query editor and next select metric now showing after selecting metric node (#22856)
* Graphite: Fixed digest issue in graphite query editor

* Fixed unit test

* Updated

(cherry picked from commit aa4ed76a00)
2020-03-19 12:27:02 +01:00
Erik Sundell
e4b4480064 Stackdriver: Fix GCE auth bug when creating new data source (#22836)
* Fix test datasource for gce auth

* Cache gce default project locally

* Await gce default project call

* Remove reload functionality

* Fix build problem

(cherry picked from commit 1cd7ce24c7)
2020-03-19 12:27:02 +01:00
Marcus Efraimsson
4e4f69b5f6 @grafana/runtime: Add cancellation of queries to DataSourceWithBackend (#22818)
(cherry picked from commit eb96a8fcc8)
2020-03-19 12:27:02 +01:00
Ivana Huckova
a4b7209e39 Rich history: Test coverage (#22852)
* Add unit test coverage

* Add tests to util/richHistory

* Remove unused import

* Remove redundant tests

* Fix tests for components

* Test saving to local storage

* Add boxshadow to container

* Revert "Add boxshadow to container"

This reverts commit 5ca2e850e4.

* Fix failing tests after merging master

* Fix imports, aria-labels

* Remove console.log

(cherry picked from commit 8edf8e3982)
2020-03-19 12:27:02 +01:00
Carl Bergquist
6c001d9c09 Datasource config was not mapped for datasource healthcheck (#22848)
closes #22825

(cherry picked from commit 0a094a7319)
2020-03-19 12:27:02 +01:00
Carl Bergquist
312600aa2c upgrades plugin sdk to 0.30.0 (#22846)
ref grafana/grafana-plugin-sdk-go#94
ref grafana/grafana-plugin-sdk-go#70

(cherry picked from commit b0407b3578)
2020-03-19 12:27:02 +01:00
Ivana Huckova
26d701dcf9 Rich History: UX adjustments and fixes (#22729)
* Initial commit

* Fix spelling of data sources

* Display sorting value for starred and query tab

* Fix handle color for light theme

* Add close button and fix animation

* Remove toggling of tabs

* Stop event propagation when clicking on comment buttons

* Add title for card functionality

* Remove interpolation for easier searchability of variables

* Improve syncing of comments and starred

* Add modal to check if user wants to permanently delete history

* Fix the height of the query card buttons

* Adjust slider's width based on drawer width

* Add spacing between slider and legend

* Semantic variable naming

* Fix disabled button when live tailing

* Add error handling

* Remove unused imports

* Fix starring, remove useEffect

* Remove emiting of appEvents.alertError in store

* Remove unused imports

(cherry picked from commit 544690060a)
2020-03-19 12:27:02 +01:00
Dominik Prokop
e347b62cee TablePanel: Enable new units picker (#22833)
(cherry picked from commit 58298919c8)
2020-03-19 12:27:02 +01:00
Alex Khomenko
36232857df Fix dashboard picker's props (#22815)
(cherry picked from commit 2fac834413)
2020-03-19 12:27:02 +01:00
Alex Khomenko
9d605bdd04 Grafana-UI: Add invalid state to Forms.Textarea (#22775)
(cherry picked from commit cd50da3dbe)
2020-03-19 12:27:02 +01:00
Torkel Ödegaard
4d235b978e SaveDashboard: Updated modal design/layout a bit (#22810)
(cherry picked from commit 46165a7f7b)
2020-03-19 12:27:02 +01:00
Torkel Ödegaard
6575c9cb6e Forms: Fix input suffix position (#22780)
* Forms: Fix input suffix position

* Update

(cherry picked from commit ab0238eced)
2020-03-19 12:27:02 +01:00
Torkel Ödegaard
0ad27a6596 AngularPanels: Fixed inner height calculation (#22796)
(cherry picked from commit f78501f3b5)
2020-03-19 12:27:02 +01:00
Hugo Häggmark
a0c6afa0a5 Fix: fixes issue with headers property with different casing (#22778)
Fixes #22756

(cherry picked from commit b30f4c7bb0)
2020-03-19 12:27:02 +01:00
Ryan McKinley
3d0bc141c7 DataSourceWithBackend: use /health endpoint for test (#22789)
(cherry picked from commit 8b067a5fe0)
2020-03-19 12:27:02 +01:00
Ryan McKinley
ed307897e7 Chore: remove expressions flag and allow (#22764)
(cherry picked from commit c65db9bf25)
2020-03-19 12:27:02 +01:00
Alex Khomenko
1d63f57caf Core: Pass the rest of to props to Select (#22776)
* Pass the rest of to props to Select

* Remove log

(cherry picked from commit 451c95808d)
2020-03-19 12:27:02 +01:00
Carl Bergquist
e00f393a17 Add support for sending health check to datasource plugins. (#22771)
closes #21519
ref grafana/grafana-plugin-sdk-go#93

(cherry picked from commit ebc9549cbc)
2020-03-19 12:27:02 +01:00
Marcus Andersson
277e00aaed Datasource: making sure we are having the same data field order when using mixed data sources. (#22718)
* changed so data query response always it returned in the correct order when using mixed data sources.

* refactored the code to make it a bit simpler and not failing the tests.

* changed to simple array type.

(cherry picked from commit f44c0f0643)
2020-03-19 12:27:02 +01:00
Dominik Prokop
ba6104190e DashboardSave: Autofocus save dashboard form input (#22748)
(cherry picked from commit b441b73345)
2020-03-19 12:27:02 +01:00
Steven Vachon
eaaca91f25 @grafana/e2e: cherry picked 4fecf5a7a6 (#22739) 2020-03-12 08:51:05 +01:00
Arve Knudsen
a551cd2470 Release version 6.7.0-beta1 (#22727) 2020-03-11 16:30:55 +01:00
119 changed files with 3269 additions and 1402 deletions

View File

@@ -45,17 +45,13 @@ jobs:
description: Install the Grafana Build Pipeline tool description: Install the Grafana Build Pipeline tool
executor: grafana-build executor: grafana-build
steps: steps:
- run:
name: Clone repo
command: |
mkdir -p ~/.ssh
echo 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==' >> ~/.ssh/known_hosts
git clone git@github.com:grafana/build-pipeline.git
- run: - run:
name: Install Grafana Build Pipeline name: Install Grafana Build Pipeline
command: | command: |
cd build-pipeline curl -fLO https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.1.1/grabpl
go build -o ../bin/grabpl ./cmd/grabpl chmod +x grabpl
mkdir bin
mv grabpl bin/
- persist_to_workspace: - persist_to_workspace:
root: . root: .
paths: paths:

View File

@@ -34,7 +34,7 @@ COPY emails emails
ENV NODE_ENV production ENV NODE_ENV production
RUN ./node_modules/.bin/grunt build RUN ./node_modules/.bin/grunt build
FROM ubuntu:19.10 FROM ubuntu:20.04
LABEL maintainer="Grafana team <hello@grafana.com>" LABEL maintainer="Grafana team <hello@grafana.com>"
EXPOSE 3000 EXPOSE 3000

View File

@@ -274,6 +274,45 @@ There are also some Grafana variables that can be used in Azure Log Analytics qu
- `$__interval` - Grafana calculates the minimum time grain that can be used to group by time in queries. More details on how it works [here]({{< relref "../../reference/templating.md#interval-variables" >}}). It returns a time grain like `5m` or `1h` that can be used in the bin function. E.g. `summarize count() by bin(TimeGenerated, $__interval)` - `$__interval` - Grafana calculates the minimum time grain that can be used to group by time in queries. More details on how it works [here]({{< relref "../../reference/templating.md#interval-variables" >}}). It returns a time grain like `5m` or `1h` that can be used in the bin function. E.g. `summarize count() by bin(TimeGenerated, $__interval)`
### Templating with Variables for Azure Log Analytics
Any Log Analytics query that returns a list of values can be used in the `Query` field in the Variable edit view. There is also one Grafana function for Log Analytics that returns a list of workspaces.
Refer to the [Variables]({{< relref "../../reference/templating.md" >}}) documentation for an introduction to the templating feature and the different
types of template variables.
| Name | Description |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| _workspaces()_ | Returns a list of workspaces for the default subscription. |
| _workspaces(12345678-aaaa-bbbb-cccc-123456789aaa)_ | Returns a list of workspaces for the specified subscription (the parameter can be quoted or unquoted). |
Example variable queries:
<!-- prettier-ignore-start -->
| Query | Description |
| --------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| _subscriptions()_ | Returns a list of Azure subscriptions |
| _workspaces()_ | Returns a list of workspaces for default subscription |
| _workspaces("12345678-aaaa-bbbb-cccc-123456789aaa")_ | Returns a list of workspaces for a specified subscription |
| _workspaces("$subscription")_ | With template variable for the subscription parameter |
| _workspace("myWorkspace").Heartbeat \| distinct Computer_ | Returns a list of Virtual Machines |
| _workspace("$workspace").Heartbeat \| distinct Computer_ | Returns a list of Virtual Machines with template variable |
| _workspace("$workspace").Perf \| distinct ObjectName_ | Returns a list of objects from the Perf table |
| _workspace("$workspace").Perf \| where ObjectName == "$object" \| distinct CounterName_ | Returns a list of metric names from the Perf table |
<!-- prettier-ignore-end -->
Example of a time series query using variables:
```
Perf
| where ObjectName == "$object" and CounterName == "$metric"
| where TimeGenerated >= $__timeFrom() and TimeGenerated <= $__timeTo()
| where $__contains(Computer, $computer)
| summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer
| order by TimeGenerated asc
```
### Azure Log Analytics Alerting ### Azure Log Analytics Alerting
Not implemented yet. Not implemented yet.

View File

@@ -45,9 +45,9 @@ Grafana 6.7 comes with a new OAuth integration for Microsoft Azure Active Direct
Allowing a low dashboard refresh interval can cause severe load on data sources and Grafana. Grafana v6.7 allows you to restrict the dashboard refresh interval so it cannot be set lower than a given interval. This provides a way for administrators to control dashboard refresh behavior on a global level. Allowing a low dashboard refresh interval can cause severe load on data sources and Grafana. Grafana v6.7 allows you to restrict the dashboard refresh interval so it cannot be set lower than a given interval. This provides a way for administrators to control dashboard refresh behavior on a global level.
Refer to min_refresh_interval in [Configuration]({{< relref "../administration/configuration/#min-refresh-interval" >}}) for further information and how to enable this. Refer to min_refresh_interval in [Configuration]({{< relref "../installation/configuration#min-refresh-interval" >}}) for more information and how to enable this feature.
### Stackdriver Project Selector ### Stackdriver project selector
A Stackdriver data source in Grafana is configured for one service account only. That service account is always associated with a default project in Google Cloud Platform (GCP). Depending on your setup in GCP, the service account might be granted access to more projects than just the default project. A Stackdriver data source in Grafana is configured for one service account only. That service account is always associated with a default project in Google Cloud Platform (GCP). Depending on your setup in GCP, the service account might be granted access to more projects than just the default project.
In Grafana 6.7, the query editor has been enhanced with a project selector that makes it possible to query different projects without changing datasource. Many thanks [Eraac](https://github.com/Eraac), [eliaslaouiti](https://github.com/eliaslaouiti) and [NaurisSadovskis](https://github.com/NaurisSadovskis) for making this happen! In Grafana 6.7, the query editor has been enhanced with a project selector that makes it possible to query different projects without changing datasource. Many thanks [Eraac](https://github.com/Eraac), [eliaslaouiti](https://github.com/eliaslaouiti) and [NaurisSadovskis](https://github.com/NaurisSadovskis) for making this happen!

View File

@@ -308,7 +308,7 @@ Example connstr: `addr=127.0.0.1:6379,pool_size=100,db=0,ssl=false`
- `addr` is the host `:` port of the redis server. - `addr` is the host `:` port of the redis server.
- `pool_size` (optional) is the number of underlying connections that can be made to redis. - `pool_size` (optional) is the number of underlying connections that can be made to redis.
- `db` (optional) is the number indentifer of the redis database you want to use. - `db` (optional) is the numerical identifier of the redis database you want to use.
- `ssl` (optional) is if SSL should be used to connect to redis server. The value may be `true`, `false`, or `insecure`. Setting the value to `insecure` skips verification of the certificate chain and hostname when making the connection. - `ssl` (optional) is if SSL should be used to connect to redis server. The value may be `true`, `false`, or `insecure`. Setting the value to `insecure` skips verification of the certificate chain and hostname when making the connection.
#### Memcache #### Memcache

9
go.mod
View File

@@ -11,7 +11,7 @@ require (
github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3 github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3
github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668 github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd // indirect github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd // indirect
github.com/crewjam/saml v0.0.0-20191031171751-c42136edf9b1 github.com/crewjam/saml v0.4.4-0.20201214083806-0dd2422c212e
github.com/davecgh/go-spew v1.1.1 github.com/davecgh/go-spew v1.1.1
github.com/denisenkom/go-mssqldb v0.0.0-20190315220205-a8ed825ac853 github.com/denisenkom/go-mssqldb v0.0.0-20190315220205-a8ed825ac853
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect
@@ -32,12 +32,13 @@ require (
github.com/gorilla/websocket v1.4.1 github.com/gorilla/websocket v1.4.1
github.com/gosimple/slug v1.4.2 github.com/gosimple/slug v1.4.2
github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4
github.com/grafana/grafana-plugin-sdk-go v0.26.0 github.com/grafana/grafana-plugin-sdk-go v0.30.0
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd
github.com/hashicorp/go-plugin v1.0.1 github.com/hashicorp/go-plugin v1.0.1
github.com/hashicorp/go-version v1.1.0 github.com/hashicorp/go-version v1.1.0
github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/jung-kurt/gofpdf v1.10.1 github.com/jung-kurt/gofpdf v1.10.1
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect
github.com/klauspost/compress v1.4.1 // indirect github.com/klauspost/compress v1.4.1 // indirect
@@ -58,7 +59,7 @@ require (
github.com/robfig/cron/v3 v3.0.0 github.com/robfig/cron/v3 v3.0.0
github.com/sergi/go-diff v1.0.0 // indirect github.com/sergi/go-diff v1.0.0 // indirect
github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337
github.com/stretchr/testify v1.4.0 github.com/stretchr/testify v1.6.1
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf
github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329 github.com/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329
github.com/uber/jaeger-client-go v2.20.1+incompatible github.com/uber/jaeger-client-go v2.20.1+incompatible
@@ -69,7 +70,7 @@ require (
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
github.com/yudai/pp v2.0.1+incompatible // indirect github.com/yudai/pp v2.0.1+incompatible // indirect
go.uber.org/atomic v1.5.1 // indirect go.uber.org/atomic v1.5.1 // indirect
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f // indirect golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f // indirect
golang.org/x/net v0.0.0-20190923162816-aa69164e4478 golang.org/x/net v0.0.0-20190923162816-aa69164e4478
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45

31
go.sum
View File

@@ -42,8 +42,11 @@ github.com/couchbase/goutils v0.0.0-20190315194238-f9d42b11473b/go.mod h1:BQwMFl
github.com/couchbaselabs/go-couchbase v0.0.0-20190708161019-23e7ca2ce2b7/go.mod h1:mby/05p8HE5yHEAKiIH/555NoblMs7PtW6NrYshDruc= github.com/couchbaselabs/go-couchbase v0.0.0-20190708161019-23e7ca2ce2b7/go.mod h1:mby/05p8HE5yHEAKiIH/555NoblMs7PtW6NrYshDruc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/crewjam/httperr v0.0.0-20190612203328-a946449404da/go.mod h1:+rmNIXRvYMqLQeR4DHyTvs6y0MEMymTz4vyFpFkKTPs=
github.com/crewjam/saml v0.0.0-20191031171751-c42136edf9b1 h1:PKeiHI5SxrkdEtI8FVdk1ubBl2wjnOmHQf5D4ZJOKFE= github.com/crewjam/saml v0.0.0-20191031171751-c42136edf9b1 h1:PKeiHI5SxrkdEtI8FVdk1ubBl2wjnOmHQf5D4ZJOKFE=
github.com/crewjam/saml v0.0.0-20191031171751-c42136edf9b1/go.mod h1:pzACCdpqjQKTvpPZs5P3FzFNQ+RSOJX5StwHwh7ZUgw= github.com/crewjam/saml v0.0.0-20191031171751-c42136edf9b1/go.mod h1:pzACCdpqjQKTvpPZs5P3FzFNQ+RSOJX5StwHwh7ZUgw=
github.com/crewjam/saml v0.4.4-0.20201214083806-0dd2422c212e h1:CFIpybPh+vrxRD6R3t2BCV9hdtlOQudsj1vB1ECXOo4=
github.com/crewjam/saml v0.4.4-0.20201214083806-0dd2422c212e/go.mod h1:qCJQpUtZte9R1ZjUBcW8qtCNlinbO363ooNl02S68bk=
github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76/go.mod h1:vYwsqCOLxGiisLwp9rITslkFNpZD5rz43tf41QFkTWY= github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76/go.mod h1:vYwsqCOLxGiisLwp9rITslkFNpZD5rz43tf41QFkTWY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -133,14 +136,8 @@ github.com/gosimple/slug v1.4.2 h1:jDmprx3q/9Lfk4FkGZtvzDQ9Cj9eAmsjzeQGp24PeiQ=
github.com/gosimple/slug v1.4.2/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0= github.com/gosimple/slug v1.4.2/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0=
github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 h1:SPdxCL9BChFTlyi0Khv64vdCW4TMna8+sxL7+Chx+Ag= github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 h1:SPdxCL9BChFTlyi0Khv64vdCW4TMna8+sxL7+Chx+Ag=
github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4/go.mod h1:nc0XxBzjeGcrMltCDw269LoWF9S8ibhgxolCdA1R8To= github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4/go.mod h1:nc0XxBzjeGcrMltCDw269LoWF9S8ibhgxolCdA1R8To=
github.com/grafana/grafana-plugin-sdk-go v0.21.0 h1:5en5MdVFgeD9tuHDuJgwHYdIVjPs0PN0a7ZQ2bZNxNk= github.com/grafana/grafana-plugin-sdk-go v0.30.0 h1:G2mA0Vsh629aTG8FkpnUmPsWtLQocwCFMLMANjT1wgg=
github.com/grafana/grafana-plugin-sdk-go v0.21.0/go.mod h1:G6Ov9M+FDOZXNw8eKXINO6XzqdUvTs7huwyQp5jLTBQ= github.com/grafana/grafana-plugin-sdk-go v0.30.0/go.mod h1:G6Ov9M+FDOZXNw8eKXINO6XzqdUvTs7huwyQp5jLTBQ=
github.com/grafana/grafana-plugin-sdk-go v0.22.1-0.20200310164332-6b4c0d952d70 h1:VQFBaWHlxwjb4VB5HuXtuucMzXJ7xZGGASzbqA3VtVo=
github.com/grafana/grafana-plugin-sdk-go v0.22.1-0.20200310164332-6b4c0d952d70/go.mod h1:G6Ov9M+FDOZXNw8eKXINO6XzqdUvTs7huwyQp5jLTBQ=
github.com/grafana/grafana-plugin-sdk-go v0.24.0 h1:sgd9rAQMmB0rAIMd4JVMFM0Gc+CTHoDwN5oxkPjVrGw=
github.com/grafana/grafana-plugin-sdk-go v0.24.0/go.mod h1:G6Ov9M+FDOZXNw8eKXINO6XzqdUvTs7huwyQp5jLTBQ=
github.com/grafana/grafana-plugin-sdk-go v0.26.0 h1:zDOZMGgGOrFF5m7+iqcQSQA/AJiG9xplNibL8SbLmn4=
github.com/grafana/grafana-plugin-sdk-go v0.26.0/go.mod h1:G6Ov9M+FDOZXNw8eKXINO6XzqdUvTs7huwyQp5jLTBQ=
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd h1:rNuUHR+CvK1IS89MMtcF0EpcVMZtjKfPRp4MEmt/aTs= github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd h1:rNuUHR+CvK1IS89MMtcF0EpcVMZtjKfPRp4MEmt/aTs=
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
github.com/hashicorp/go-plugin v1.0.1 h1:4OtAfUGbnKC6yS48p0CtMX2oFYtzFZVv6rok3cRWgnE= github.com/hashicorp/go-plugin v1.0.1 h1:4OtAfUGbnKC6yS48p0CtMX2oFYtzFZVv6rok3cRWgnE=
@@ -164,6 +161,11 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5i
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/jonboulle/clockwork v0.2.1 h1:S/EaQvW6FpWMYAvYvY+OBDvpaM+izu0oiwo5y0MH7U0=
github.com/jonboulle/clockwork v0.2.1/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok= github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -185,6 +187,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
@@ -196,6 +199,8 @@ github.com/linkedin/goavro/v2 v2.9.7 h1:Vd++Rb/RKcmNJjM0HP/JJFMEWa21eUBVKPYlKehO
github.com/linkedin/goavro/v2 v2.9.7/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= github.com/linkedin/goavro/v2 v2.9.7/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA=
github.com/lunny/log v0.0.0-20160921050905-7887c61bf0de/go.mod h1:3q8WtuPQsoRbatJuy3nvq/hRSvuBJrHHr+ybPPiNvHQ= github.com/lunny/log v0.0.0-20160921050905-7887c61bf0de/go.mod h1:3q8WtuPQsoRbatJuy3nvq/hRSvuBJrHHr+ybPPiNvHQ=
github.com/lunny/nodb v0.0.0-20160621015157-fc1ef06ad4af/go.mod h1:Cqz6pqow14VObJ7peltM+2n3PWOz7yTrfUuGbVFkzN0= github.com/lunny/nodb v0.0.0-20160621015157-fc1ef06ad4af/go.mod h1:Cqz6pqow14VObJ7peltM+2n3PWOz7yTrfUuGbVFkzN0=
github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e h1:qqXczln0qwkVGcpQ+sQuPOVntt2FytYarXXxYSNJkgw=
github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
github.com/mattetti/filebuffer v1.0.0 h1:ixTvQ0JjBTwWbdpDZ98lLrydo7KRi8xNRIi5RFszsbY= github.com/mattetti/filebuffer v1.0.0 h1:ixTvQ0JjBTwWbdpDZ98lLrydo7KRi8xNRIi5RFszsbY=
github.com/mattetti/filebuffer v1.0.0/go.mod h1:X6nyAIge2JGVmuJt2MFCqmHrb/5IHiphfHtot0s5cnI= github.com/mattetti/filebuffer v1.0.0/go.mod h1:X6nyAIge2JGVmuJt2MFCqmHrb/5IHiphfHtot0s5cnI=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
@@ -262,6 +267,8 @@ github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7 h1:J4AOUcOh/t1XbQcJfkEqhzgvMJ2tDxdCVvmHxW5QXao= github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7 h1:J4AOUcOh/t1XbQcJfkEqhzgvMJ2tDxdCVvmHxW5QXao=
github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7/go.mod h1:Oz4y6ImuOQZxynhbSXk7btjEfNBtGlj2dcaOvXl2FSM= github.com/russellhaering/goxmldsig v0.0.0-20180430223755-7acd5e4a6ef7/go.mod h1:Oz4y6ImuOQZxynhbSXk7btjEfNBtGlj2dcaOvXl2FSM=
github.com/russellhaering/goxmldsig v1.1.0 h1:lK/zeJie2sqG52ZAlPNn1oBBqsIsEKypUUBGpYYF6lk=
github.com/russellhaering/goxmldsig v1.1.0/go.mod h1:QK8GhXPB3+AfuCrfo0oRISa9NfzeCpWmxeGnqEpDF9o=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
@@ -296,6 +303,8 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA=
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0=
@@ -334,6 +343,8 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392 h1:ACG4HJsFiNMf47Y4PeRoeb
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9 h1:sYNJzB4J8toYPQTM6pAkcmBRgw9SnQKP9oXCHfgy604=
golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20190507092727-e4e5bf290fec/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190507092727-e4e5bf290fec/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -385,11 +396,13 @@ golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa h1:KIDDMLT1O0Nr7TSxp8xM5tJcd
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69 h1:rOhMmluY6kLMhdnrivzec6lLgaVbMHMn2ISQXJeJ5EM= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69 h1:rOhMmluY6kLMhdnrivzec6lLgaVbMHMn2ISQXJeJ5EM=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f h1:68K/z8GLUxV76xGSqwTWw2gyk/jwn79LUL43rES2g8o= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f h1:68K/z8GLUxV76xGSqwTWw2gyk/jwn79LUL43rES2g8o=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -467,6 +480,8 @@ gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c= gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -2,5 +2,5 @@
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"packages": ["packages/*"], "packages": ["packages/*"],
"version": "6.7.0-pre" "version": "6.7.6"
} }

View File

@@ -3,7 +3,7 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"name": "grafana", "name": "grafana",
"version": "6.7.0-pre", "version": "6.7.6",
"repository": "github:grafana/grafana", "repository": "github:grafana/grafana",
"devDependencies": { "devDependencies": {
"@babel/core": "7.8.4", "@babel/core": "7.8.4",
@@ -186,6 +186,10 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"watch": "yarn start -d watch,start core:start --watchTheme " "watch": "yarn start -d watch,start core:start --watchTheme "
}, },
"grafana": {
"whatsNewUrl": "https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-7/",
"releaseNotesUrl": "https://community.grafana.com/t/release-notes-v6-7-x/"
},
"husky": { "husky": {
"hooks": { "hooks": {
"pre-commit": "lint-staged && npm run precommit" "pre-commit": "lint-staged && npm run precommit"

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs", "author": "Grafana Labs",
"license": "Apache-2.0", "license": "Apache-2.0",
"name": "@grafana/data", "name": "@grafana/data",
"version": "6.7.0-pre", "version": "6.7.6",
"description": "Grafana Data Library", "description": "Grafana Data Library",
"keywords": [ "keywords": [
"typescript" "typescript"

View File

@@ -1,7 +1,7 @@
import sinon, { SinonFakeTimers } from 'sinon'; import sinon, { SinonFakeTimers } from 'sinon';
import each from 'lodash/each'; import each from 'lodash/each';
import { dateMath } from './datemath'; import * as dateMath from './datemath';
import { dateTime, DurationUnit, DateTime } from './moment_wrapper'; import { dateTime, DurationUnit, DateTime } from './moment_wrapper';
describe('DateMath', () => { describe('DateMath', () => {

View File

@@ -5,157 +5,158 @@ import { TimeZone } from '../types/index';
const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's']; const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's'];
// eslint-disable-next-line @typescript-eslint/no-namespace export function isMathString(text: string | DateTime | Date): boolean {
export namespace dateMath { if (!text) {
export function isMathString(text: string | DateTime | Date): boolean {
if (!text) {
return false;
}
if (typeof text === 'string' && (text.substring(0, 3) === 'now' || text.includes('||'))) {
return true;
} else {
return false;
}
}
/**
* Parses different types input to a moment instance. There is a specific formatting language that can be used
* if text arg is string. See unit tests for examples.
* @param text
* @param roundUp See parseDateMath function.
* @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used.
*/
export function parse(text: string | DateTime | Date, roundUp?: boolean, timezone?: TimeZone): DateTime | undefined {
if (!text) {
return undefined;
}
if (typeof text !== 'string') {
if (isDateTime(text)) {
return text;
}
if (isDate(text)) {
return dateTime(text);
}
// We got some non string which is not a moment nor Date. TS should be able to check for that but not always.
return undefined;
} else {
let time;
let mathString = '';
let index;
let parseString;
if (text.substring(0, 3) === 'now') {
time = dateTimeForTimeZone(timezone);
mathString = text.substring('now'.length);
} else {
index = text.indexOf('||');
if (index === -1) {
parseString = text;
mathString = ''; // nothing else
} else {
parseString = text.substring(0, index);
mathString = text.substring(index + 2);
}
// We're going to just require ISO8601 timestamps, k?
time = dateTime(parseString, ISO_8601);
}
if (!mathString.length) {
return time;
}
return parseDateMath(mathString, time, roundUp);
}
}
/**
* Checks if text is a valid date which in this context means that it is either a Moment instance or it can be parsed
* by parse function. See parse function to see what is considered acceptable.
* @param text
*/
export function isValid(text: string | DateTime): boolean {
const date = parse(text);
if (!date) {
return false;
}
if (isDateTime(date)) {
return date.isValid();
}
return false; return false;
} }
/** if (typeof text === 'string' && (text.substring(0, 3) === 'now' || text.includes('||'))) {
* Parses math part of the time string and shifts supplied time according to that math. See unit tests for examples. return true;
* @param mathString } else {
* @param time return false;
* @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit. }
*/ }
// TODO: Had to revert Andrejs `time: moment.Moment` to `time: any`
export function parseDateMath(mathString: string, time: any, roundUp?: boolean): DateTime | undefined {
const strippedMathString = mathString.replace(/\s/g, '');
const dateTime = time;
let i = 0;
const len = strippedMathString.length;
while (i < len) { /**
const c = strippedMathString.charAt(i++); * Parses different types input to a moment instance. There is a specific formatting language that can be used
let type; * if text arg is string. See unit tests for examples.
let num; * @param text
let unit; * @param roundUp See parseDateMath function.
* @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used.
*/
export function parse(
text?: string | DateTime | Date | null,
roundUp?: boolean,
timezone?: TimeZone
): DateTime | undefined {
if (!text) {
return undefined;
}
if (c === '/') { if (typeof text !== 'string') {
type = 0; if (isDateTime(text)) {
} else if (c === '+') { return text;
type = 1; }
} else if (c === '-') { if (isDate(text)) {
type = 2; return dateTime(text);
}
// We got some non string which is not a moment nor Date. TS should be able to check for that but not always.
return undefined;
} else {
let time;
let mathString = '';
let index;
let parseString;
if (text.substring(0, 3) === 'now') {
time = dateTimeForTimeZone(timezone);
mathString = text.substring('now'.length);
} else {
index = text.indexOf('||');
if (index === -1) {
parseString = text;
mathString = ''; // nothing else
} else { } else {
return undefined; parseString = text.substring(0, index);
mathString = text.substring(index + 2);
} }
// We're going to just require ISO8601 timestamps, k?
time = dateTime(parseString, ISO_8601);
}
if (isNaN(parseInt(strippedMathString.charAt(i), 10))) { if (!mathString.length) {
num = 1; return time;
} else if (strippedMathString.length === 2) { }
num = strippedMathString.charAt(i);
} else {
const numFrom = i;
while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) {
i++;
if (i > 10) {
return undefined;
}
}
num = parseInt(strippedMathString.substring(numFrom, i), 10);
}
if (type === 0) { return parseDateMath(mathString, time, roundUp);
// rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M) }
if (num !== 1) { }
/**
* Checks if text is a valid date which in this context means that it is either a Moment instance or it can be parsed
* by parse function. See parse function to see what is considered acceptable.
* @param text
*/
export function isValid(text: string | DateTime): boolean {
const date = parse(text);
if (!date) {
return false;
}
if (isDateTime(date)) {
return date.isValid();
}
return false;
}
/**
* Parses math part of the time string and shifts supplied time according to that math. See unit tests for examples.
* @param mathString
* @param time
* @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit.
*/
// TODO: Had to revert Andrejs `time: moment.Moment` to `time: any`
export function parseDateMath(mathString: string, time: any, roundUp?: boolean): DateTime | undefined {
const strippedMathString = mathString.replace(/\s/g, '');
const dateTime = time;
let i = 0;
const len = strippedMathString.length;
while (i < len) {
const c = strippedMathString.charAt(i++);
let type;
let num;
let unit;
if (c === '/') {
type = 0;
} else if (c === '+') {
type = 1;
} else if (c === '-') {
type = 2;
} else {
return undefined;
}
if (isNaN(parseInt(strippedMathString.charAt(i), 10))) {
num = 1;
} else if (strippedMathString.length === 2) {
num = strippedMathString.charAt(i);
} else {
const numFrom = i;
while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) {
i++;
if (i > 10) {
return undefined; return undefined;
} }
} }
unit = strippedMathString.charAt(i++); num = parseInt(strippedMathString.substring(numFrom, i), 10);
}
if (!includes(units, unit)) { if (type === 0) {
// rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M)
if (num !== 1) {
return undefined; return undefined;
} else {
if (type === 0) {
if (roundUp) {
dateTime.endOf(unit);
} else {
dateTime.startOf(unit);
}
} else if (type === 1) {
dateTime.add(num, unit);
} else if (type === 2) {
dateTime.subtract(num, unit);
}
} }
} }
return dateTime; unit = strippedMathString.charAt(i++);
if (!includes(units, unit)) {
return undefined;
} else {
if (type === 0) {
if (roundUp) {
dateTime.endOf(unit);
} else {
dateTime.startOf(unit);
}
} else if (type === 1) {
dateTime.add(num, unit);
} else if (type === 2) {
dateTime.subtract(num, unit);
}
}
} }
return dateTime;
} }

View File

@@ -1,6 +1,7 @@
// Names are too general to export globally // Names are too general to export globally
export { dateMath } from './datemath'; import * as dateMath from './datemath';
export { rangeUtil } from './rangeutil'; import * as rangeUtil from './rangeutil';
export * from './moment_wrapper'; export * from './moment_wrapper';
export * from './timezones'; export * from './timezones';
export * from './formats'; export * from './formats';
export { dateMath, rangeUtil };

View File

@@ -3,183 +3,180 @@ import groupBy from 'lodash/groupBy';
import { RawTimeRange } from '../types/time'; import { RawTimeRange } from '../types/time';
import { dateMath } from './datemath'; import * as dateMath from './datemath';
import { isDateTime, DateTime } from './moment_wrapper'; import { isDateTime, DateTime } from './moment_wrapper';
// eslint-disable-next-line @typescript-eslint/no-namespace const spans: { [key: string]: { display: string; section?: number } } = {
export namespace rangeUtil { s: { display: 'second' },
const spans: { [key: string]: { display: string; section?: number } } = { m: { display: 'minute' },
s: { display: 'second' }, h: { display: 'hour' },
m: { display: 'minute' }, d: { display: 'day' },
h: { display: 'hour' }, w: { display: 'week' },
d: { display: 'day' }, M: { display: 'month' },
w: { display: 'week' }, y: { display: 'year' },
M: { display: 'month' }, };
y: { display: 'year' },
};
const rangeOptions = [ const rangeOptions = [
{ from: 'now/d', to: 'now/d', display: 'Today', section: 2 }, { from: 'now/d', to: 'now/d', display: 'Today', section: 2 },
{ from: 'now/d', to: 'now', display: 'Today so far', section: 2 }, { from: 'now/d', to: 'now', display: 'Today so far', section: 2 },
{ from: 'now/w', to: 'now/w', display: 'This week', section: 2 }, { from: 'now/w', to: 'now/w', display: 'This week', section: 2 },
{ from: 'now/w', to: 'now', display: 'This week so far', section: 2 }, { from: 'now/w', to: 'now', display: 'This week so far', section: 2 },
{ from: 'now/M', to: 'now/M', display: 'This month', section: 2 }, { from: 'now/M', to: 'now/M', display: 'This month', section: 2 },
{ from: 'now/M', to: 'now', display: 'This month so far', section: 2 }, { from: 'now/M', to: 'now', display: 'This month so far', section: 2 },
{ from: 'now/y', to: 'now/y', display: 'This year', section: 2 }, { from: 'now/y', to: 'now/y', display: 'This year', section: 2 },
{ from: 'now/y', to: 'now', display: 'This year so far', section: 2 }, { from: 'now/y', to: 'now', display: 'This year so far', section: 2 },
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 1 }, { from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 1 },
{ {
from: 'now-2d/d', from: 'now-2d/d',
to: 'now-2d/d', to: 'now-2d/d',
display: 'Day before yesterday', display: 'Day before yesterday',
section: 1, section: 1,
}, },
{ {
from: 'now-7d/d', from: 'now-7d/d',
to: 'now-7d/d', to: 'now-7d/d',
display: 'This day last week', display: 'This day last week',
section: 1, section: 1,
}, },
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 1 }, { from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 1 },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 1 }, { from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 1 },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 }, { from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 },
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 }, { from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 }, { from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 }, { from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 }, { from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 }, { from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 }, { from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 }, { from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 }, { from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 }, { from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 },
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 }, { from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 },
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 }, { from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 },
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 0 }, { from: 'now-90d', to: 'now', display: 'Last 90 days', section: 0 },
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 0 }, { from: 'now-6M', to: 'now', display: 'Last 6 months', section: 0 },
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 0 }, { from: 'now-1y', to: 'now', display: 'Last 1 year', section: 0 },
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 0 }, { from: 'now-2y', to: 'now', display: 'Last 2 years', section: 0 },
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 0 }, { from: 'now-5y', to: 'now', display: 'Last 5 years', section: 0 },
]; ];
const absoluteFormat = 'YYYY-MM-DD HH:mm:ss'; const absoluteFormat = 'YYYY-MM-DD HH:mm:ss';
const rangeIndex: any = {}; const rangeIndex: any = {};
each(rangeOptions, (frame: any) => { each(rangeOptions, (frame: any) => {
rangeIndex[frame.from + ' to ' + frame.to] = frame; rangeIndex[frame.from + ' to ' + frame.to] = frame;
});
export function getRelativeTimesList(timepickerSettings: any, currentDisplay: any) {
const groups = groupBy(rangeOptions, (option: any) => {
option.active = option.display === currentDisplay;
return option.section;
}); });
export function getRelativeTimesList(timepickerSettings: any, currentDisplay: any) { // _.each(timepickerSettings.time_options, (duration: string) => {
const groups = groupBy(rangeOptions, (option: any) => { // let info = describeTextRange(duration);
option.active = option.display === currentDisplay; // if (info.section) {
return option.section; // groups[info.section].push(info);
}); // }
// });
// _.each(timepickerSettings.time_options, (duration: string) => { return groups;
// let info = describeTextRange(duration); }
// if (info.section) {
// groups[info.section].push(info);
// }
// });
return groups; function formatDate(date: DateTime) {
return date.format(absoluteFormat);
}
// handles expressions like
// 5m
// 5m to now/d
// now/d to now
// now/d
// if no to <expr> then to now is assumed
export function describeTextRange(expr: any) {
const isLast = expr.indexOf('+') !== 0;
if (expr.indexOf('now') === -1) {
expr = (isLast ? 'now-' : 'now') + expr;
} }
function formatDate(date: DateTime) { let opt = rangeIndex[expr + ' to now'];
return date.format(absoluteFormat); if (opt) {
}
// handles expressions like
// 5m
// 5m to now/d
// now/d to now
// now/d
// if no to <expr> then to now is assumed
export function describeTextRange(expr: any) {
const isLast = expr.indexOf('+') !== 0;
if (expr.indexOf('now') === -1) {
expr = (isLast ? 'now-' : 'now') + expr;
}
let opt = rangeIndex[expr + ' to now'];
if (opt) {
return opt;
}
if (isLast) {
opt = { from: expr, to: 'now' };
} else {
opt = { from: 'now', to: expr };
}
const parts = /^now([-+])(\d+)(\w)/.exec(expr);
if (parts) {
const unit = parts[3];
const amount = parseInt(parts[2], 10);
const span = spans[unit];
if (span) {
opt.display = isLast ? 'Last ' : 'Next ';
opt.display += amount + ' ' + span.display;
opt.section = span.section;
if (amount > 1) {
opt.display += 's';
}
}
} else {
opt.display = opt.from + ' to ' + opt.to;
opt.invalid = true;
}
return opt; return opt;
} }
/** if (isLast) {
* Use this function to get a properly formatted string representation of a {@link @grafana/data:RawTimeRange | range}. opt = { from: expr, to: 'now' };
* } else {
* @example opt = { from: 'now', to: expr };
* ```
* // Prints "2":
* console.log(add(1,1));
* ```
* @category TimeUtils
* @param range - a time range (usually specified by the TimePicker)
* @alpha
*/
export function describeTimeRange(range: RawTimeRange): string {
const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()];
if (option) {
return option.display;
}
if (isDateTime(range.from) && isDateTime(range.to)) {
return formatDate(range.from) + ' to ' + formatDate(range.to);
}
if (isDateTime(range.from)) {
const toMoment = dateMath.parse(range.to, true);
return toMoment ? formatDate(range.from) + ' to ' + toMoment.fromNow() : '';
}
if (isDateTime(range.to)) {
const from = dateMath.parse(range.from, false);
return from ? from.fromNow() + ' to ' + formatDate(range.to) : '';
}
if (range.to.toString() === 'now') {
const res = describeTextRange(range.from);
return res.display;
}
return range.from.toString() + ' to ' + range.to.toString();
} }
export const isValidTimeSpan = (value: string) => { const parts = /^now([-+])(\d+)(\w)/.exec(expr);
if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) { if (parts) {
return true; const unit = parts[3];
const amount = parseInt(parts[2], 10);
const span = spans[unit];
if (span) {
opt.display = isLast ? 'Last ' : 'Next ';
opt.display += amount + ' ' + span.display;
opt.section = span.section;
if (amount > 1) {
opt.display += 's';
}
} }
} else {
opt.display = opt.from + ' to ' + opt.to;
opt.invalid = true;
}
const info = describeTextRange(value); return opt;
return info.invalid !== true;
};
} }
/**
* Use this function to get a properly formatted string representation of a {@link @grafana/data:RawTimeRange | range}.
*
* @example
* ```
* // Prints "2":
* console.log(add(1,1));
* ```
* @category TimeUtils
* @param range - a time range (usually specified by the TimePicker)
* @alpha
*/
export function describeTimeRange(range: RawTimeRange): string {
const option = rangeIndex[range.from.toString() + ' to ' + range.to.toString()];
if (option) {
return option.display;
}
if (isDateTime(range.from) && isDateTime(range.to)) {
return formatDate(range.from) + ' to ' + formatDate(range.to);
}
if (isDateTime(range.from)) {
const toMoment = dateMath.parse(range.to, true);
return toMoment ? formatDate(range.from) + ' to ' + toMoment.fromNow() : '';
}
if (isDateTime(range.to)) {
const from = dateMath.parse(range.from, false);
return from ? from.fromNow() + ' to ' + formatDate(range.to) : '';
}
if (range.to.toString() === 'now') {
const res = describeTextRange(range.from);
return res.display;
}
return range.from.toString() + ' to ' + range.to.toString();
}
export const isValidTimeSpan = (value: string) => {
if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) {
return true;
}
const info = describeTextRange(value);
return info.invalid !== true;
};

View File

@@ -5,11 +5,8 @@ export interface AppEvent<T> {
payload?: T; payload?: T;
} }
// eslint-disable-next-line @typescript-eslint/no-namespace export type AlertPayload = [string, string?];
export namespace AppEvents {
export type AlertPayload = [string, string?];
export const alertSuccess = eventFactory<AlertPayload>('alert-success'); export const alertSuccess = eventFactory<AlertPayload>('alert-success');
export const alertWarning = eventFactory<AlertPayload>('alert-warning'); export const alertWarning = eventFactory<AlertPayload>('alert-warning');
export const alertError = eventFactory<AlertPayload>('alert-error'); export const alertError = eventFactory<AlertPayload>('alert-error');
}

View File

@@ -24,5 +24,9 @@ export * from './theme';
export * from './orgs'; export * from './orgs';
export * from './flot'; export * from './flot';
export { AppEvent, AppEvents } from './appEvents'; import * as AppEvents from './appEvents';
export { PanelEvents } from './panelEvents'; import { AppEvent } from './appEvents';
export { AppEvent, AppEvents };
import * as PanelEvents from './panelEvents';
export { PanelEvents };

View File

@@ -2,28 +2,25 @@ import { eventFactory } from './utils';
import { DataQueryError, DataQueryResponseData } from './datasource'; import { DataQueryError, DataQueryResponseData } from './datasource';
import { AngularPanelMenuItem } from './panel'; import { AngularPanelMenuItem } from './panel';
// eslint-disable-next-line @typescript-eslint/no-namespace /** Payloads */
export namespace PanelEvents { export interface PanelChangeViewPayload {
/** Payloads */ fullscreen?: boolean;
export interface PanelChangeViewPayload { edit?: boolean;
fullscreen?: boolean; panelId?: number;
edit?: boolean; toggle?: boolean;
panelId?: number;
toggle?: boolean;
}
/** Events */
export const refresh = eventFactory('refresh');
export const componentDidMount = eventFactory('component-did-mount');
export const dataError = eventFactory<DataQueryError>('data-error');
export const dataReceived = eventFactory<DataQueryResponseData[]>('data-received');
export const dataSnapshotLoad = eventFactory<DataQueryResponseData[]>('data-snapshot-load');
export const editModeInitialized = eventFactory('init-edit-mode');
export const initPanelActions = eventFactory<AngularPanelMenuItem[]>('init-panel-actions');
export const panelChangeView = eventFactory<PanelChangeViewPayload>('panel-change-view');
export const panelInitialized = eventFactory('panel-initialized');
export const panelSizeChanged = eventFactory('panel-size-changed');
export const panelTeardown = eventFactory('panel-teardown');
export const render = eventFactory<any>('render');
export const viewModeChanged = eventFactory('view-mode-changed');
} }
/** Events */
export const refresh = eventFactory('refresh');
export const componentDidMount = eventFactory('component-did-mount');
export const dataError = eventFactory<DataQueryError>('data-error');
export const dataReceived = eventFactory<DataQueryResponseData[]>('data-received');
export const dataSnapshotLoad = eventFactory<DataQueryResponseData[]>('data-snapshot-load');
export const editModeInitialized = eventFactory('init-edit-mode');
export const initPanelActions = eventFactory<AngularPanelMenuItem[]>('init-panel-actions');
export const panelChangeView = eventFactory<PanelChangeViewPayload>('panel-change-view');
export const panelInitialized = eventFactory('panel-initialized');
export const panelSizeChanged = eventFactory('panel-size-changed');
export const panelTeardown = eventFactory('panel-teardown');
export const render = eventFactory<any>('render');
export const viewModeChanged = eventFactory('view-mode-changed');

View File

@@ -1,9 +1,23 @@
const { resolve } = require('path');
const wp = require('@cypress/webpack-preprocessor'); const wp = require('@cypress/webpack-preprocessor');
const anyNodeModules = /node_modules/;
const packageRoot = resolve(`${__dirname}/../../`);
const packageModules = `${packageRoot}/node_modules`;
const webpackOptions = { const webpackOptions = {
module: { module: {
rules: [ rules: [
{ {
include: modulePath => {
if (!anyNodeModules.test(modulePath)) {
// Is a file within the project
return true;
} else {
// Is a file within this package
return modulePath.startsWith(packageRoot) && !modulePath.startsWith(packageModules);
}
},
test: /\.ts$/, test: /\.ts$/,
use: [ use: [
{ {

View File

@@ -1,7 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"declaration": false,
"module": "commonjs",
"types": ["cypress"] "types": ["cypress"]
}, },
"extends": "../tsconfig.json", "extends": "@grafana/tsconfig",
"include": ["**/*.ts"] "include": ["**/*.ts"]
} }

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs", "author": "Grafana Labs",
"license": "Apache-2.0", "license": "Apache-2.0",
"name": "@grafana/e2e", "name": "@grafana/e2e",
"version": "6.7.0-pre", "version": "6.7.6",
"description": "Grafana End-to-End Test Library", "description": "Grafana End-to-End Test Library",
"keywords": [ "keywords": [
"cli", "cli",

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs", "author": "Grafana Labs",
"license": "Apache-2.0", "license": "Apache-2.0",
"name": "@grafana/runtime", "name": "@grafana/runtime",
"version": "6.7.0-pre", "version": "6.7.6",
"description": "Grafana Runtime Library", "description": "Grafana Runtime Library",
"keywords": [ "keywords": [
"grafana", "grafana",
@@ -23,8 +23,8 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@grafana/data": "6.7.0-pre", "@grafana/data": "6.7.6",
"@grafana/ui": "6.7.0-pre", "@grafana/ui": "6.7.6",
"systemjs": "0.20.19", "systemjs": "0.20.19",
"systemjs-plugin-css": "0.1.37" "systemjs-plugin-css": "0.1.37"
}, },

View File

@@ -13,6 +13,18 @@ import { getBackendSrv } from '../services';
// Ideally internal (exported for consistency) // Ideally internal (exported for consistency)
const ExpressionDatasourceID = '__expr__'; const ExpressionDatasourceID = '__expr__';
export enum HealthStatus {
Unknown = 'UNKNOWN',
OK = 'OK',
Error = 'ERROR',
}
export interface HealthCheckResult {
status: HealthStatus;
message: string;
details?: Record<string, any>;
}
export class DataSourceWithBackend< export class DataSourceWithBackend<
TQuery extends DataQuery = DataQuery, TQuery extends DataQuery = DataQuery,
TOptions extends DataSourceJsonData = DataSourceJsonData TOptions extends DataSourceJsonData = DataSourceJsonData
@@ -22,16 +34,13 @@ export class DataSourceWithBackend<
} }
/** /**
* Ideally final -- any other implementation would be wrong! * Ideally final -- any other implementation may not work as expected
*/ */
query(request: DataQueryRequest): Observable<DataQueryResponse> { query(request: DataQueryRequest): Observable<DataQueryResponse> {
const { targets, intervalMs, maxDataPoints, range } = request; const { targets, intervalMs, maxDataPoints, range, requestId } = request;
let expressionCount = 0;
const orgId = config.bootData.user.orgId; const orgId = config.bootData.user.orgId;
const queries = targets.map(q => { const queries = targets.map(q => {
if (q.datasource === ExpressionDatasourceID) { if (q.datasource === ExpressionDatasourceID) {
expressionCount++;
return { return {
...q, ...q,
datasourceId: this.id, datasourceId: this.id,
@@ -53,7 +62,6 @@ export class DataSourceWithBackend<
}); });
const body: any = { const body: any = {
expressionCount,
queries, queries,
}; };
if (range) { if (range) {
@@ -63,10 +71,16 @@ export class DataSourceWithBackend<
} }
const req: Promise<DataQueryResponse> = getBackendSrv() const req: Promise<DataQueryResponse> = getBackendSrv()
.post('/api/ds/query', body) .datasourceRequest({
url: '/api/ds/query',
method: 'POST',
data: body,
requestId,
})
.then((rsp: any) => { .then((rsp: any) => {
return this.toDataQueryResponse(rsp); return this.toDataQueryResponse(rsp?.data);
}); });
return from(req); return from(req);
} }
@@ -101,8 +115,36 @@ export class DataSourceWithBackend<
return getBackendSrv().post(`/api/datasources/${this.id}/resources/${path}`, { ...body }); return getBackendSrv().post(`/api/datasources/${this.id}/resources/${path}`, { ...body });
} }
testDatasource() { /**
// TODO, this will call the backend healthcheck endpoint * Run the datasource healthcheck
return Promise.resolve({}); */
async callHealthCheck(): Promise<HealthCheckResult> {
return getBackendSrv()
.get(`/api/datasources/${this.id}/health`)
.then(v => {
return v as HealthCheckResult;
})
.catch(err => {
err.isHandled = true; // Avoid extra popup warning
return err.data as HealthCheckResult;
});
}
/**
* Checks the plugin health
*/
async testDatasource(): Promise<any> {
return this.callHealthCheck().then(res => {
if (res.status === HealthStatus.OK) {
return {
status: 'success',
message: res.message,
};
}
return {
status: 'fail',
message: res.message,
};
});
} }
} }

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs", "author": "Grafana Labs",
"license": "Apache-2.0", "license": "Apache-2.0",
"name": "@grafana/toolkit", "name": "@grafana/toolkit",
"version": "6.7.0-pre", "version": "6.7.6",
"description": "Grafana Toolkit", "description": "Grafana Toolkit",
"keywords": [ "keywords": [
"grafana", "grafana",
@@ -27,12 +27,12 @@
}, },
"main": "src/index.ts", "main": "src/index.ts",
"dependencies": { "dependencies": {
"@babel/core": "7.8.3", "@babel/core": "7.9.0",
"@babel/preset-env": "7.8.3", "@babel/preset-env": "7.9.0",
"@grafana/data": "6.7.0-pre", "@grafana/data": "6.7.6",
"@grafana/eslint-config": "^1.0.0-rc1", "@grafana/eslint-config": "^1.0.0-rc1",
"@grafana/tsconfig": "^1.0.0-rc1", "@grafana/tsconfig": "^1.0.0-rc1",
"@grafana/ui": "6.7.0-pre", "@grafana/ui": "6.7.6",
"@types/command-exists": "^1.2.0", "@types/command-exists": "^1.2.0",
"@types/execa": "^0.9.0", "@types/execa": "^0.9.0",
"@types/expect-puppeteer": "3.3.1", "@types/expect-puppeteer": "3.3.1",
@@ -52,7 +52,7 @@
"@typescript-eslint/parser": "2.19.2", "@typescript-eslint/parser": "2.19.2",
"axios": "0.19.0", "axios": "0.19.0",
"babel-jest": "24.8.0", "babel-jest": "24.8.0",
"babel-loader": "8.0.6", "babel-loader": "8.1.0",
"babel-plugin-angularjs-annotate": "0.10.0", "babel-plugin-angularjs-annotate": "0.10.0",
"chalk": "^2.4.2", "chalk": "^2.4.2",
"command-exists": "^1.2.8", "command-exists": "^1.2.8",

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs", "author": "Grafana Labs",
"license": "Apache-2.0", "license": "Apache-2.0",
"name": "@grafana/ui", "name": "@grafana/ui",
"version": "6.7.0-pre", "version": "6.7.6",
"description": "Grafana Components Library", "description": "Grafana Components Library",
"keywords": [ "keywords": [
"grafana", "grafana",
@@ -28,7 +28,7 @@
}, },
"dependencies": { "dependencies": {
"@emotion/core": "^10.0.27", "@emotion/core": "^10.0.27",
"@grafana/data": "6.7.0-pre", "@grafana/data": "6.7.6",
"@grafana/slate-react": "0.22.9-grafana", "@grafana/slate-react": "0.22.9-grafana",
"@grafana/tsconfig": "^1.0.0-rc1", "@grafana/tsconfig": "^1.0.0-rc1",
"@torkelo/react-select": "3.0.8", "@torkelo/react-select": "3.0.8",

View File

@@ -14,6 +14,8 @@ import { RadioButtonGroup } from './RadioButtonGroup/RadioButtonGroup';
import { Select } from './Select/Select'; import { Select } from './Select/Select';
import Forms from './index'; import Forms from './index';
import mdx from './Form.mdx'; import mdx from './Form.mdx';
import { boolean } from '@storybook/addon-knobs';
import { TextArea } from './TextArea/TextArea';
export default { export default {
title: 'Forms/Test forms', title: 'Forms/Test forms',
@@ -48,6 +50,7 @@ interface FormDTO {
switch: boolean; switch: boolean;
radio: string; radio: string;
select: string; select: string;
text: string;
nested: { nested: {
path: string; path: string;
}; };
@@ -86,6 +89,10 @@ const renderForm = (defaultValues?: Partial<FormDTO>) => (
<Input name="nested.path" placeholder="Nested path" size="md" ref={register} /> <Input name="nested.path" placeholder="Nested path" size="md" ref={register} />
</Field> </Field>
<Field label="Textarea" invalid={!!errors.text} error="Text is required">
<TextArea name="text" placeholder="Long text" size="md" ref={register({ required: true })} />
</Field>
<Field label="Checkbox" invalid={!!errors.checkbox} error="We need your consent"> <Field label="Checkbox" invalid={!!errors.checkbox} error="We need your consent">
<Checkbox name="checkbox" label="Do you consent?" ref={register({ required: true })} /> <Checkbox name="checkbox" label="Do you consent?" ref={register({ required: true })} />
</Field> </Field>

View File

@@ -47,15 +47,10 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
height: 100%; height: 100%;
/* Min width specified for prefix/suffix classes used outside React component*/ /* Min width specified for prefix/suffix classes used outside React component*/
min-width: ${prefixSuffixStaticWidth}; min-width: ${prefixSuffixStaticWidth};
// Hack to fix font awesome icons
> .fa {
position: relative;
top: 2px;
}
`; `;
return { return {
// Wraps inputWraper and addons // Wraps inputWrapper and addons
wrapper: cx( wrapper: cx(
css` css`
label: input-wrapper; label: input-wrapper;
@@ -154,37 +149,36 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
color: ${colors.formInputDisabledText}; color: ${colors.formInputDisabledText};
`, `,
addon: css` addon: css`
label: input-addon; label: input-addon;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;
&:first-child { &:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
> :last-child {
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
> :last-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
} }
}
&:last-child { &:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
> :first-child {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
> :first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
} }
> *:focus { }
/* we want anything that has focus and is an addon to be above input */ > *:focus {
z-index: 2; /* we want anything that has focus and is an addon to be above input */
} z-index: 2;
} }
`, `,
prefix: cx( prefix: cx(
prefixSuffix, prefixSuffix,
css` css`

View File

@@ -14,7 +14,6 @@ export const IndicatorsContainer = React.forwardRef<HTMLDivElement, React.PropsW
styles.suffix, styles.suffix,
css` css`
position: relative; position: relative;
top: 1px;
` `
)} )}
ref={ref} ref={ref}

View File

@@ -15,6 +15,7 @@ export function MultiSelect<T>(props: MultiSelectCommonProps<T>) {
interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> { interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options // AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
value?: SelectableValue<T>; value?: SelectableValue<T>;
invalid?: boolean;
} }
export function AsyncSelect<T>(props: AsyncSelectProps<T>) { export function AsyncSelect<T>(props: AsyncSelectProps<T>) {

View File

@@ -32,6 +32,7 @@ const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) =>
border-radius: ${theme.border.radius.sm}; border-radius: ${theme.border.radius.sm};
padding: ${theme.spacing.formSpacingBase / 4}px ${theme.spacing.formSpacingBase}px; padding: ${theme.spacing.formSpacingBase / 4}px ${theme.spacing.formSpacingBase}px;
width: 100%; width: 100%;
border-color: ${invalid ? theme.colors.redBase : theme.colors.formInputBorder};
` `
), ),
}; };

View File

@@ -10,6 +10,7 @@ import { Field } from './Field';
import { Button, LinkButton } from './Button'; import { Button, LinkButton } from './Button';
import { Switch } from './Switch'; import { Switch } from './Switch';
import { TextArea } from './TextArea/TextArea'; import { TextArea } from './TextArea/TextArea';
import { Checkbox } from './Checkbox';
const Forms = { const Forms = {
RadioButtonGroup, RadioButtonGroup,
@@ -26,6 +27,7 @@ const Forms = {
InputControl, InputControl,
AsyncSelect, AsyncSelect,
TextArea, TextArea,
Checkbox,
}; };
export { ButtonVariant } from './Button'; export { ButtonVariant } from './Button';

View File

@@ -122,7 +122,7 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__value-container { .gf-form-select-box__value-container {
display: inline-block; display: inline-block;
padding: 6px 16px 6px 10px; padding: 8px 16px 8px 10px;
vertical-align: middle; vertical-align: middle;
> div { > div {

View File

@@ -3,4 +3,6 @@ import { getTheme, mockTheme } from './getTheme';
import { selectThemeVariant } from './selectThemeVariant'; import { selectThemeVariant } from './selectThemeVariant';
export { stylesFactory } from './stylesFactory'; export { stylesFactory } from './stylesFactory';
export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant, useTheme, mockThemeContext }; export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant, useTheme, mockThemeContext };
export { styleMixins } from './mixins';
import * as styleMixins from './mixins';
export { styleMixins };

View File

@@ -1,10 +1,8 @@
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
// eslint-disable-next-line @typescript-eslint/no-namespace export function cardChrome(theme: GrafanaTheme): string {
export namespace styleMixins { if (theme.isDark) {
export function cardChrome(theme: GrafanaTheme): string { return `
if (theme.isDark) {
return `
background: linear-gradient(135deg, ${theme.colors.dark8}, ${theme.colors.dark6}); background: linear-gradient(135deg, ${theme.colors.dark8}, ${theme.colors.dark6});
&:hover { &:hover {
background: linear-gradient(135deg, ${theme.colors.dark9}, ${theme.colors.dark6}); background: linear-gradient(135deg, ${theme.colors.dark9}, ${theme.colors.dark6});
@@ -12,9 +10,9 @@ export namespace styleMixins {
box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.3); box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.3);
border-radius: ${theme.border.radius.md}; border-radius: ${theme.border.radius.md};
`; `;
} }
return ` return `
background: linear-gradient(135deg, ${theme.colors.gray6}, ${theme.colors.gray7}); background: linear-gradient(135deg, ${theme.colors.gray6}, ${theme.colors.gray7});
&:hover { &:hover {
background: linear-gradient(135deg, ${theme.colors.gray7}, ${theme.colors.gray6}); background: linear-gradient(135deg, ${theme.colors.gray7}, ${theme.colors.gray6});
@@ -22,11 +20,11 @@ export namespace styleMixins {
box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.1); box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.1);
border-radius: ${theme.border.radius.md}; border-radius: ${theme.border.radius.md};
`; `;
} }
export function listItem(theme: GrafanaTheme): string { export function listItem(theme: GrafanaTheme): string {
if (theme.isDark) { if (theme.isDark) {
return ` return `
background: ${theme.colors.dark7}; background: ${theme.colors.dark7};
&:hover { &:hover {
background: ${theme.colors.dark9}; background: ${theme.colors.dark9};
@@ -34,9 +32,9 @@ export namespace styleMixins {
box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.3); box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.3);
border-radius: ${theme.border.radius.md}; border-radius: ${theme.border.radius.md};
`; `;
} }
return ` return `
background: ${theme.colors.gray7}; background: ${theme.colors.gray7};
&:hover { &:hover {
background: ${theme.colors.gray6}; background: ${theme.colors.gray6};
@@ -44,5 +42,4 @@ export namespace styleMixins {
box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.1); box-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, 0.1), 1px 1px 0 0 rgba(0, 0, 0, 0.1);
border-radius: ${theme.border.radius.md}; border-radius: ${theme.border.radius.md};
`; `;
}
} }

View File

@@ -1,44 +1,41 @@
// eslint-disable-next-line @typescript-eslint/no-namespace // Node.closest() polyfill
export namespace DOMUtil { if ('Element' in window && !Element.prototype.closest) {
// Node.closest() polyfill Element.prototype.closest = function(this: any, s: string) {
if ('Element' in window && !Element.prototype.closest) { const matches = (this.document || this.ownerDocument).querySelectorAll(s);
Element.prototype.closest = function(this: any, s: string) { let el = this;
const matches = (this.document || this.ownerDocument).querySelectorAll(s); let i;
let el = this; // eslint-disable-next-line
let i; do {
i = matches.length;
// eslint-disable-next-line // eslint-disable-next-line
do { while (--i >= 0 && matches.item(i) !== el) {}
i = matches.length; el = el.parentElement;
// eslint-disable-next-line } while (i < 0 && el);
while (--i >= 0 && matches.item(i) !== el) {} return el;
el = el.parentElement; };
} while (i < 0 && el); }
return el;
}; export function getPreviousCousin(node: any, selector: string) {
} let sibling = node.parentElement.previousSibling;
let el;
export function getPreviousCousin(node: any, selector: string) { while (sibling) {
let sibling = node.parentElement.previousSibling; el = sibling.querySelector(selector);
let el; if (el) {
while (sibling) { return el;
el = sibling.querySelector(selector); }
if (el) { sibling = sibling.previousSibling;
return el; }
} return undefined;
sibling = sibling.previousSibling; }
}
return undefined; export function getNextCharacter(global?: any) {
} const selection = (global || window).getSelection();
if (!selection || !selection.anchorNode) {
export function getNextCharacter(global?: any) { return null;
const selection = (global || window).getSelection(); }
if (!selection || !selection.anchorNode) {
return null; const range = selection.getRangeAt(0);
} const text = selection.anchorNode.textContent;
const offset = range.startOffset;
const range = selection.getRangeAt(0); return text!.substr(offset, 1);
const text = selection.anchorNode.textContent;
const offset = range.startOffset;
return text!.substr(offset, 1);
}
} }

View File

@@ -6,5 +6,5 @@ export * from './tags';
export * from './measureText'; export * from './measureText';
export { default as ansicolor } from './ansicolor'; export { default as ansicolor } from './ansicolor';
// Export with a namespace import * as DOMUtil from './dom'; // includes Element.closest polyfil
export { DOMUtil } from './dom'; // includes Element.closest polyfil export { DOMUtil };

View File

@@ -64,7 +64,7 @@ docker_build () {
else else
libc="" libc=""
dockerfile="ubuntu.Dockerfile" dockerfile="ubuntu.Dockerfile"
base_image="${base_arch}ubuntu:19.10" base_image="${base_arch}ubuntu:20.04"
fi fi
grafana_tgz="grafana-latest.linux-${arch}${libc}.tar.gz" grafana_tgz="grafana-latest.linux-${arch}${libc}.tar.gz"

View File

@@ -1,4 +1,4 @@
ARG BASE_IMAGE=ubuntu:19.10 ARG BASE_IMAGE=ubuntu:20.04
FROM ${BASE_IMAGE} AS grafana-builder FROM ${BASE_IMAGE} AS grafana-builder
ARG GRAFANA_TGZ="grafana-latest.linux-x64.tar.gz" ARG GRAFANA_TGZ="grafana-latest.linux-x64.tar.gz"

View File

@@ -258,6 +258,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
pluginRoute.Get("/:pluginId/dashboards/", Wrap(GetPluginDashboards)) pluginRoute.Get("/:pluginId/dashboards/", Wrap(GetPluginDashboards))
pluginRoute.Post("/:pluginId/settings", bind(models.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting)) pluginRoute.Post("/:pluginId/settings", bind(models.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting))
pluginRoute.Get("/:pluginId/metrics", Wrap(hs.CollectPluginMetrics))
}, reqOrgAdmin) }, reqOrgAdmin)
apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings) apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
@@ -265,6 +266,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)
apiRoute.Any("/datasources/:id/resources", hs.CallDatasourceResource) apiRoute.Any("/datasources/:id/resources", hs.CallDatasourceResource)
apiRoute.Any("/datasources/:id/resources/*", hs.CallDatasourceResource) apiRoute.Any("/datasources/:id/resources/*", hs.CallDatasourceResource)
apiRoute.Any("/datasources/:id/health", hs.CheckDatasourceHealth)
// Folders // Folders
apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {

View File

@@ -15,14 +15,14 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"gopkg.in/macaron.v1"
gocache "github.com/patrickmn/go-cache" gocache "github.com/patrickmn/go-cache"
) )
@@ -73,9 +73,15 @@ type CacheServer struct {
cache *gocache.Cache cache *gocache.Cache
} }
func (this *CacheServer) Handler(ctx *macaron.Context) { var validMD5 = regexp.MustCompile("^[a-fA-F0-9]{32}$")
urlPath := ctx.Req.URL.Path
hash := urlPath[strings.LastIndex(urlPath, "/")+1:] func (this *CacheServer) Handler(ctx *models.ReqContext) {
hash := ctx.Params("hash")
if len(hash) != 32 || !validMD5.MatchString(hash) {
ctx.JsonApiErr(404, "Avatar not found", nil)
return
}
var avatar *Avatar var avatar *Avatar
obj, exists := this.cache.Get(hash) obj, exists := this.cache.Get(hash)

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"encoding/json"
"sort" "sort"
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
@@ -323,3 +324,90 @@ func convertModelToDtos(ds *models.DataSource) dtos.DataSource {
return dto return dto
} }
// CheckDatasourceHealth sends a health check request to the plugin datasource
// /api/datasource/:id/health
func (hs *HTTPServer) CheckDatasourceHealth(c *models.ReqContext) {
datasourceID := c.ParamsInt64("id")
ds, err := hs.DatasourceCache.GetDatasource(datasourceID, c.SignedInUser, c.SkipCache)
if err != nil {
if err == models.ErrDataSourceAccessDenied {
c.JsonApiErr(403, "Access denied to datasource", err)
return
}
c.JsonApiErr(500, "Unable to load datasource metadata", err)
return
}
plugin, ok := hs.PluginManager.GetDatasource(ds.Type)
if !ok {
c.JsonApiErr(500, "Unable to find datasource plugin", err)
return
}
config := &backendplugin.PluginConfig{
OrgID: c.OrgId,
PluginID: plugin.Id,
DataSourceConfig: &backendplugin.DataSourceConfig{
ID: ds.Id,
Name: ds.Name,
URL: ds.Url,
Database: ds.Database,
User: ds.User,
BasicAuthEnabled: ds.BasicAuth,
BasicAuthUser: ds.BasicAuthUser,
JSONData: ds.JsonData,
DecryptedSecureJSONData: ds.DecryptedValues(),
Updated: ds.Updated,
},
}
resp, err := hs.BackendPluginManager.CheckHealth(c.Req.Context(), config)
if err != nil {
if err == backendplugin.ErrPluginNotRegistered {
c.JsonApiErr(404, "Plugin not found", err)
return
}
// Return status unknown instead?
if err == backendplugin.ErrDiagnosticsNotSupported {
c.JsonApiErr(404, "Health check not implemented", err)
return
}
// Return status unknown or error instead?
if err == backendplugin.ErrHealthCheckFailed {
c.JsonApiErr(500, "Plugin health check failed", err)
return
}
c.JsonApiErr(500, "Plugin healthcheck returned an unknown error", err)
return
}
var jsonDetails map[string]interface{}
payload := map[string]interface{}{
"status": resp.Status.String(),
"message": resp.Message,
"details": jsonDetails,
}
// Unmarshal JSONDetails if it's not empty.
if len(resp.JSONDetails) > 0 {
err = json.Unmarshal(resp.JSONDetails, &jsonDetails)
if err != nil {
c.JsonApiErr(500, "Failed to unmarshal detailed response from backend plugin", err)
return
}
payload["details"] = jsonDetails
}
if resp.Status != backendplugin.HealthStatusOk {
c.JSON(503, payload)
return
}
c.JSON(200, payload)
}

View File

@@ -73,6 +73,7 @@ type HTTPServer struct {
Login *login.LoginService `inject:""` Login *login.LoginService `inject:""`
License models.Licensing `inject:""` License models.Licensing `inject:""`
BackendPluginManager backendplugin.Manager `inject:""` BackendPluginManager backendplugin.Manager `inject:""`
PluginManager *plugins.PluginManager `inject:""`
} }
func (hs *HTTPServer) Init() error { func (hs *HTTPServer) Init() error {

View File

@@ -19,10 +19,6 @@ import (
// QueryMetricsV2 returns query metrics // QueryMetricsV2 returns query metrics
// POST /api/ds/query DataSource query w/ expressions // POST /api/ds/query DataSource query w/ expressions
func (hs *HTTPServer) QueryMetricsV2(c *models.ReqContext, reqDto dtos.MetricRequest) Response { func (hs *HTTPServer) QueryMetricsV2(c *models.ReqContext, reqDto dtos.MetricRequest) Response {
if !setting.IsExpressionsEnabled() {
return Error(404, "Expressions feature toggle is not enabled", nil)
}
if len(reqDto.Queries) == 0 { if len(reqDto.Queries) == 0 {
return Error(500, "No queries found in query", nil) return Error(500, "No queries found in query", nil)
} }
@@ -76,6 +72,10 @@ func (hs *HTTPServer) QueryMetricsV2(c *models.ReqContext, reqDto dtos.MetricReq
return Error(500, "Metric request error", err) return Error(500, "Metric request error", err)
} }
} else { } else {
if !setting.IsExpressionsEnabled() {
return Error(404, "Expressions feature toggle is not enabled", nil)
}
resp, err = plugins.Transform.Transform(c.Req.Context(), request) resp, err = plugins.Transform.Transform(c.Req.Context(), request)
if err != nil { if err != nil {
return Error(500, "Transform request error", err) return Error(500, "Transform request error", err)

View File

@@ -1,10 +1,13 @@
package api package api
import ( import (
"errors"
"net/http"
"sort" "sort"
"time" "time"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
@@ -14,6 +17,41 @@ import (
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
// ErrPluginNotFound is returned when an requested plugin is not installed.
var ErrPluginNotFound error = errors.New("plugin not found, no installed plugin with that id")
func (hs *HTTPServer) getPluginConfig(pluginID string, user *models.SignedInUser) (backendplugin.PluginConfig, error) {
pluginConfig := backendplugin.PluginConfig{}
plugin, exists := plugins.Plugins[pluginID]
if !exists {
return pluginConfig, ErrPluginNotFound
}
var jsonData *simplejson.Json
var decryptedSecureJSONData map[string]string
var updated time.Time
ps, err := hs.getCachedPluginSettings(pluginID, user)
if err != nil {
if err != models.ErrPluginSettingNotFound {
return pluginConfig, errutil.Wrap("Failed to get plugin settings", err)
}
jsonData = simplejson.New()
decryptedSecureJSONData = make(map[string]string)
} else {
decryptedSecureJSONData = ps.DecryptedValues()
updated = ps.Updated
}
return backendplugin.PluginConfig{
OrgID: user.OrgId,
PluginID: plugin.Id,
JSONData: jsonData,
DecryptedSecureJSONData: decryptedSecureJSONData,
Updated: updated,
}, nil
}
func (hs *HTTPServer) GetPluginList(c *models.ReqContext) Response { func (hs *HTTPServer) GetPluginList(c *models.ReqContext) Response {
typeFilter := c.Query("type") typeFilter := c.Query("type")
enabledFilter := c.Query("enabled") enabledFilter := c.Query("enabled")
@@ -205,11 +243,54 @@ func ImportDashboard(c *models.ReqContext, apiCmd dtos.ImportDashboardCommand) R
return JSON(200, cmd.Result) return JSON(200, cmd.Result)
} }
// CollectPluginMetrics collect metrics from a plugin.
//
// /api/plugins/:pluginId/metrics
func (hs *HTTPServer) CollectPluginMetrics(c *models.ReqContext) Response {
pluginID := c.Params("pluginId")
plugin, exists := plugins.Plugins[pluginID]
if !exists {
return Error(404, "Plugin not found, no installed plugin with that id", nil)
}
resp, err := hs.BackendPluginManager.CollectMetrics(c.Req.Context(), plugin.Id)
if err != nil {
if err == backendplugin.ErrPluginNotRegistered {
return Error(404, "Plugin not found", err)
}
if err == backendplugin.ErrDiagnosticsNotSupported {
return Error(404, "Health check not implemented", err)
}
return Error(500, "Collect plugin metrics failed", err)
}
headers := make(http.Header)
headers.Set("Content-Type", "text/plain")
return &NormalResponse{
header: headers,
body: resp.PrometheusMetrics,
status: http.StatusOK,
}
}
// CheckHealth returns the health of a plugin. // CheckHealth returns the health of a plugin.
// /api/plugins/:pluginId/health // /api/plugins/:pluginId/health
func (hs *HTTPServer) CheckHealth(c *models.ReqContext) Response { func (hs *HTTPServer) CheckHealth(c *models.ReqContext) Response {
pluginID := c.Params("pluginId") pluginID := c.Params("pluginId")
resp, err := hs.BackendPluginManager.CheckHealth(c.Req.Context(), pluginID)
config, err := hs.getPluginConfig(pluginID, c.SignedInUser)
if err != nil {
if err == ErrPluginNotFound {
return Error(404, "Plugin not found, no installed plugin with that id", nil)
}
return Error(500, "Failed to get plugin settings", err)
}
resp, err := hs.BackendPluginManager.CheckHealth(c.Req.Context(), &config)
if err != nil { if err != nil {
if err == backendplugin.ErrPluginNotRegistered { if err == backendplugin.ErrPluginNotRegistered {
return Error(404, "Plugin not found", err) return Error(404, "Plugin not found", err)
@@ -224,6 +305,8 @@ func (hs *HTTPServer) CheckHealth(c *models.ReqContext) Response {
if err == backendplugin.ErrHealthCheckFailed { if err == backendplugin.ErrHealthCheckFailed {
return Error(500, "Plugin health check failed", err) return Error(500, "Plugin health check failed", err)
} }
return Error(500, "Plugin healthcheck returned an unknown error", err)
} }
payload := map[string]interface{}{ payload := map[string]interface{}{
@@ -239,39 +322,23 @@ func (hs *HTTPServer) CheckHealth(c *models.ReqContext) Response {
return JSON(200, payload) return JSON(200, payload)
} }
// CallResource passes a resource call from a plugin to the backend plugin.
//
// /api/plugins/:pluginId/resources/* // /api/plugins/:pluginId/resources/*
func (hs *HTTPServer) CallResource(c *models.ReqContext) { func (hs *HTTPServer) CallResource(c *models.ReqContext) {
pluginID := c.Params("pluginId") pluginID := c.Params("pluginId")
plugin, exists := plugins.Plugins[pluginID]
if !exists { config, err := hs.getPluginConfig(pluginID, c.SignedInUser)
c.JsonApiErr(404, "Plugin not found, no installed plugin with that id", nil) if err != nil {
if err == ErrPluginNotFound {
c.JsonApiErr(404, "Plugin not found, no installed plugin with that id", nil)
return
}
c.JsonApiErr(500, "Failed to get plugin settings", err)
return return
} }
var jsonData *simplejson.Json
var decryptedSecureJSONData map[string]string
var updated time.Time
ps, err := hs.getCachedPluginSettings(pluginID, c.SignedInUser)
if err != nil {
if err != models.ErrPluginSettingNotFound {
c.JsonApiErr(500, "Failed to get plugin settings", err)
return
}
jsonData = simplejson.New()
decryptedSecureJSONData = make(map[string]string)
} else {
decryptedSecureJSONData = ps.DecryptedValues()
updated = ps.Updated
}
config := backendplugin.PluginConfig{
OrgID: c.OrgId,
PluginID: plugin.Id,
JSONData: jsonData,
DecryptedSecureJSONData: decryptedSecureJSONData,
Updated: updated,
}
hs.BackendPluginManager.CallResource(config, c, c.Params("*")) hs.BackendPluginManager.CallResource(config, c, c.Params("*"))
} }

View File

@@ -115,8 +115,10 @@ func main() {
go listenToSystemSignals(server) go listenToSystemSignals(server)
err := server.Run() err := server.Run()
code := 0
code := server.ExitCode(err) if err != nil {
code = server.ExitCode(err)
}
trace.Stop() trace.Stop()
log.Close() log.Close()

View File

@@ -1,8 +1,9 @@
package authproxy package authproxy
import ( import (
"encoding/base32" "encoding/hex"
"fmt" "fmt"
"hash/fnv"
"net" "net"
"net/mail" "net/mail"
"reflect" "reflect"
@@ -146,6 +147,13 @@ func (auth *AuthProxy) IsAllowedIP() (bool, *Error) {
return false, newError("Proxy authentication required", err) return false, newError("Proxy authentication required", err)
} }
func HashCacheKey(key string) string {
hasher := fnv.New128a()
// according to the documentation, Hash.Write cannot error, but linter is complaining
hasher.Write([]byte(key)) // nolint: errcheck
return hex.EncodeToString(hasher.Sum(nil))
}
// getKey forms a key for the cache based on the headers received as part of the authentication flow. // getKey forms a key for the cache based on the headers received as part of the authentication flow.
// Our configuration supports multiple headers. The main header contains the email or username. // Our configuration supports multiple headers. The main header contains the email or username.
// And the additional ones that allow us to specify extra attributes: Name, Email or Groups. // And the additional ones that allow us to specify extra attributes: Name, Email or Groups.
@@ -156,7 +164,7 @@ func (auth *AuthProxy) getKey() string {
key = strings.Join([]string{key, header}, "-") // compose the key with any additional headers key = strings.Join([]string{key, header}, "-") // compose the key with any additional headers
}) })
hashedKey := base32.StdEncoding.EncodeToString([]byte(key)) hashedKey := HashCacheKey(key)
return fmt.Sprintf(CachePrefix, hashedKey) return fmt.Sprintf(CachePrefix, hashedKey)
} }

View File

@@ -1,7 +1,6 @@
package authproxy package authproxy
import ( import (
"encoding/base32"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -79,7 +78,7 @@ func TestMiddlewareContext(t *testing.T) {
Convey("with a simple cache key", func() { Convey("with a simple cache key", func() {
// Set cache key // Set cache key
key := fmt.Sprintf(CachePrefix, base32.StdEncoding.EncodeToString([]byte(name))) key := fmt.Sprintf(CachePrefix, HashCacheKey(name))
err := store.Set(key, int64(33), 0) err := store.Set(key, int64(33), 0)
So(err, ShouldBeNil) So(err, ShouldBeNil)
@@ -88,7 +87,7 @@ func TestMiddlewareContext(t *testing.T) {
id, err := auth.Login() id, err := auth.Login()
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:NVQXE23FNRXWO===") So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:0a7f3374e9659b10980fd66247b0cf2f")
So(id, ShouldEqual, 33) So(id, ShouldEqual, 33)
}) })
@@ -97,7 +96,7 @@ func TestMiddlewareContext(t *testing.T) {
group := "grafana-core-team" group := "grafana-core-team"
req.Header.Add("X-WEBAUTH-GROUPS", group) req.Header.Add("X-WEBAUTH-GROUPS", group)
key := fmt.Sprintf(CachePrefix, base32.StdEncoding.EncodeToString([]byte(name+"-"+group))) key := fmt.Sprintf(CachePrefix, HashCacheKey(name+"-"+group))
err := store.Set(key, int64(33), 0) err := store.Set(key, int64(33), 0)
So(err, ShouldBeNil) So(err, ShouldBeNil)
@@ -105,7 +104,7 @@ func TestMiddlewareContext(t *testing.T) {
id, err := auth.Login() id, err := auth.Login()
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:NVQXE23FNRXWOLLHOJQWMYLOMEWWG33SMUWXIZLBNU======") So(auth.getKey(), ShouldEqual, "auth-proxy-sync-ttl:14f69b7023baa0ac98c96b31cec07bc0")
So(id, ShouldEqual, 33) So(id, ShouldEqual, 33)
}) })

View File

@@ -2,7 +2,6 @@ package middleware
import ( import (
"context" "context"
"encoding/base32"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -364,7 +363,7 @@ func TestMiddlewareContext(t *testing.T) {
return nil return nil
}) })
key := fmt.Sprintf(authproxy.CachePrefix, base32.StdEncoding.EncodeToString([]byte(name+"-"+group))) key := fmt.Sprintf(authproxy.CachePrefix, authproxy.HashCacheKey(name+"-"+group))
err := sc.remoteCacheService.Set(key, int64(33), 0) err := sc.remoteCacheService.Set(key, int64(33), 0)
So(err, ShouldBeNil) So(err, ShouldBeNil)
sc.fakeReq("GET", "/") sc.fakeReq("GET", "/")

View File

@@ -1,26 +1,20 @@
package backendplugin package backendplugin
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt"
"net/http" "net/http"
"time" "time"
"github.com/grafana/grafana-plugin-sdk-go/genproto/pluginv2" "github.com/grafana/grafana-plugin-sdk-go/genproto/pluginv2"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/expfmt"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
datasourceV1 "github.com/grafana/grafana-plugin-model/go/datasource" datasourceV1 "github.com/grafana/grafana-plugin-model/go/datasource"
rendererV1 "github.com/grafana/grafana-plugin-model/go/renderer" rendererV1 "github.com/grafana/grafana-plugin-model/go/renderer"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins/backendplugin/collector"
"github.com/grafana/grafana/pkg/util/errutil" "github.com/grafana/grafana/pkg/util/errutil"
plugin "github.com/hashicorp/go-plugin" plugin "github.com/hashicorp/go-plugin"
dto "github.com/prometheus/client_model/go"
) )
// BackendPlugin a registered backend plugin. // BackendPlugin a registered backend plugin.
@@ -140,59 +134,70 @@ func (p *BackendPlugin) supportsDiagnostics() bool {
} }
// CollectMetrics implements the collector.Collector interface. // CollectMetrics implements the collector.Collector interface.
func (p *BackendPlugin) CollectMetrics(ctx context.Context, ch chan<- prometheus.Metric) error { func (p *BackendPlugin) CollectMetrics(ctx context.Context) (*pluginv2.CollectMetricsResponse, error) {
if p.diagnostics == nil { if p.diagnostics == nil || p.client == nil || p.client.Exited() {
return nil return &pluginv2.CollectMetricsResponse{
} Metrics: &pluginv2.CollectMetricsResponse_Payload{},
}, nil
if p.client == nil || p.client.Exited() {
return nil
} }
res, err := p.diagnostics.CollectMetrics(ctx, &pluginv2.CollectMetricsRequest{}) res, err := p.diagnostics.CollectMetrics(ctx, &pluginv2.CollectMetricsRequest{})
if err != nil { if err != nil {
if st, ok := status.FromError(err); ok { if st, ok := status.FromError(err); ok {
if st.Code() == codes.Unimplemented { if st.Code() == codes.Unimplemented {
return nil return &pluginv2.CollectMetricsResponse{
Metrics: &pluginv2.CollectMetricsResponse_Payload{},
}, nil
} }
} }
return err return nil, err
} }
if res == nil || res.Metrics == nil || res.Metrics.Prometheus == nil { return res, nil
return nil
}
reader := bytes.NewReader(res.Metrics.Prometheus)
var parser expfmt.TextParser
families, err := parser.TextToMetricFamilies(reader)
if err != nil {
return errutil.Wrap("failed to parse collected metrics", err)
}
for _, mf := range families {
if mf.Help == nil {
help := fmt.Sprintf("Metric read from %s plugin", p.id)
mf.Help = &help
}
}
for _, mf := range families {
convertMetricFamily(p.id, mf, ch, p.logger)
}
return nil
} }
func (p *BackendPlugin) checkHealth(ctx context.Context) (*pluginv2.CheckHealthResponse, error) { func (p *BackendPlugin) checkHealth(ctx context.Context, config *PluginConfig) (*pluginv2.CheckHealthResponse, error) {
if p.diagnostics == nil || p.client == nil || p.client.Exited() { if p.diagnostics == nil || p.client == nil || p.client.Exited() {
return &pluginv2.CheckHealthResponse{ return &pluginv2.CheckHealthResponse{
Status: pluginv2.CheckHealthResponse_UNKNOWN, Status: pluginv2.CheckHealthResponse_UNKNOWN,
}, nil }, nil
} }
res, err := p.diagnostics.CheckHealth(ctx, &pluginv2.CheckHealthRequest{}) jsonDataBytes, err := config.JSONData.ToDB()
if err != nil {
return nil, err
}
pconfig := &pluginv2.PluginConfig{
OrgId: config.OrgID,
PluginId: config.PluginID,
JsonData: jsonDataBytes,
DecryptedSecureJsonData: config.DecryptedSecureJSONData,
LastUpdatedMS: config.Updated.UnixNano() / int64(time.Millisecond),
}
if config.DataSourceConfig != nil {
datasourceJSONData, err := config.DataSourceConfig.JSONData.ToDB()
if err != nil {
return nil, err
}
pconfig.DatasourceConfig = &pluginv2.DataSourceConfig{
Id: config.DataSourceConfig.ID,
Name: config.DataSourceConfig.Name,
Url: config.DataSourceConfig.URL,
User: config.DataSourceConfig.User,
Database: config.DataSourceConfig.Database,
BasicAuthEnabled: config.DataSourceConfig.BasicAuthEnabled,
BasicAuthUser: config.DataSourceConfig.BasicAuthUser,
JsonData: datasourceJSONData,
DecryptedSecureJsonData: config.DataSourceConfig.DecryptedSecureJSONData,
LastUpdatedMS: config.DataSourceConfig.Updated.Unix() / int64(time.Millisecond),
}
}
res, err := p.diagnostics.CheckHealth(ctx, &pluginv2.CheckHealthRequest{Config: pconfig})
if err != nil { if err != nil {
if st, ok := status.FromError(err); ok { if st, ok := status.FromError(err); ok {
if st.Code() == codes.Unimplemented { if st.Code() == codes.Unimplemented {
@@ -288,112 +293,3 @@ func (p *BackendPlugin) callResource(ctx context.Context, req CallResourceReques
stream: protoStream, stream: protoStream,
}, nil }, nil
} }
// convertMetricFamily converts metric family to prometheus.Metric.
// Copied from https://github.com/prometheus/node_exporter/blob/3ddc82c2d8d11eec53ed5faa8db969a1bb81f8bb/collector/textfile.go#L66-L165
func convertMetricFamily(pluginID string, metricFamily *dto.MetricFamily, ch chan<- prometheus.Metric, logger log.Logger) {
var valType prometheus.ValueType
var val float64
allLabelNames := map[string]struct{}{}
for _, metric := range metricFamily.Metric {
labels := metric.GetLabel()
for _, label := range labels {
if _, ok := allLabelNames[label.GetName()]; !ok {
allLabelNames[label.GetName()] = struct{}{}
}
}
}
for _, metric := range metricFamily.Metric {
if metric.TimestampMs != nil {
logger.Warn("Ignoring unsupported custom timestamp on metric", "metric", metric)
}
labels := metric.GetLabel()
var names []string
var values []string
for _, label := range labels {
names = append(names, label.GetName())
values = append(values, label.GetValue())
}
names = append(names, "plugin_id")
values = append(values, pluginID)
for k := range allLabelNames {
present := false
for _, name := range names {
if k == name {
present = true
break
}
}
if !present {
names = append(names, k)
values = append(values, "")
}
}
metricName := prometheus.BuildFQName(collector.Namespace, "", *metricFamily.Name)
metricType := metricFamily.GetType()
switch metricType {
case dto.MetricType_COUNTER:
valType = prometheus.CounterValue
val = metric.Counter.GetValue()
case dto.MetricType_GAUGE:
valType = prometheus.GaugeValue
val = metric.Gauge.GetValue()
case dto.MetricType_UNTYPED:
valType = prometheus.UntypedValue
val = metric.Untyped.GetValue()
case dto.MetricType_SUMMARY:
quantiles := map[float64]float64{}
for _, q := range metric.Summary.Quantile {
quantiles[q.GetQuantile()] = q.GetValue()
}
ch <- prometheus.MustNewConstSummary(
prometheus.NewDesc(
metricName,
metricFamily.GetHelp(),
names, nil,
),
metric.Summary.GetSampleCount(),
metric.Summary.GetSampleSum(),
quantiles, values...,
)
case dto.MetricType_HISTOGRAM:
buckets := map[float64]uint64{}
for _, b := range metric.Histogram.Bucket {
buckets[b.GetUpperBound()] = b.GetCumulativeCount()
}
ch <- prometheus.MustNewConstHistogram(
prometheus.NewDesc(
metricName,
metricFamily.GetHelp(),
names, nil,
),
metric.Histogram.GetSampleCount(),
metric.Histogram.GetSampleSum(),
buckets, values...,
)
default:
logger.Error("unknown metric type", "type", metricType)
continue
}
if metricType == dto.MetricType_GAUGE || metricType == dto.MetricType_COUNTER || metricType == dto.MetricType_UNTYPED {
ch <- prometheus.MustNewConstMetric(
prometheus.NewDesc(
metricName,
metricFamily.GetHelp(),
names, nil,
),
valType, val, values...,
)
}
}
}

View File

@@ -101,7 +101,7 @@ func NewRendererPluginDescriptor(pluginID, executablePath string, startFns Plugi
} }
type DiagnosticsPlugin interface { type DiagnosticsPlugin interface {
plugin.DiagnosticsServer plugin.DiagnosticsClient
} }
type ResourcePlugin interface { type ResourcePlugin interface {

View File

@@ -1,89 +0,0 @@
package collector
import (
"context"
"sync"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/prometheus/client_golang/prometheus"
)
// Namespace collector metric namespace
const Namespace = "grafana_plugin"
var (
scrapeDurationDesc = prometheus.NewDesc(
prometheus.BuildFQName(Namespace, "scrape", "duration_seconds"),
"grafana_plugin: Duration of a plugin collector scrape.",
[]string{"plugin_id"},
nil,
)
scrapeSuccessDesc = prometheus.NewDesc(
prometheus.BuildFQName(Namespace, "scrape", "success"),
"grafana_plugin: Whether a plugin collector succeeded.",
[]string{"plugin_id"},
nil,
)
)
// Collector is the interface a plugin collector has to implement.
type Collector interface {
// Get new metrics and expose them via prometheus registry.
CollectMetrics(ctx context.Context, ch chan<- prometheus.Metric) error
}
// PluginCollector implements the prometheus.Collector interface.
type PluginCollector struct {
collectors map[string]Collector
logger log.Logger
}
// NewPluginCollector creates a new PluginCollector..
func NewPluginCollector() PluginCollector {
return PluginCollector{
collectors: make(map[string]Collector),
logger: log.New("plugins.backend.collector"),
}
}
func (pc PluginCollector) Register(pluginID string, c Collector) {
pc.collectors[pluginID] = c
}
// Describe implements the prometheus.Collector interface.
func (pc PluginCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- scrapeDurationDesc
ch <- scrapeSuccessDesc
}
// Collect implements the prometheus.Collector interface.
func (pc PluginCollector) Collect(ch chan<- prometheus.Metric) {
ctx := context.Background()
wg := sync.WaitGroup{}
wg.Add(len(pc.collectors))
for name, c := range pc.collectors {
go func(name string, c Collector) {
execute(ctx, name, c, ch, pc.logger)
wg.Done()
}(name, c)
}
wg.Wait()
}
func execute(ctx context.Context, pluginID string, c Collector, ch chan<- prometheus.Metric, logger log.Logger) {
begin := time.Now()
err := c.CollectMetrics(ctx, ch)
duration := time.Since(begin)
var success float64
if err != nil {
logger.Error("collector failed", "pluginId", pluginID, "took", duration, "error", err)
success = 0
} else {
logger.Debug("collector succeeded", "pluginId", pluginID, "took", duration)
success = 1
}
ch <- prometheus.MustNewConstMetric(scrapeDurationDesc, prometheus.GaugeValue, duration.Seconds(), pluginID)
ch <- prometheus.MustNewConstMetric(scrapeSuccessDesc, prometheus.GaugeValue, success, pluginID)
}

View File

@@ -40,7 +40,7 @@ func (hs HealthStatus) String() string {
type CheckHealthResult struct { type CheckHealthResult struct {
Status HealthStatus Status HealthStatus
Message string Message string
JSONDetails string JSONDetails []byte
} }
func checkHealthResultFromProto(protoResp *pluginv2.CheckHealthResponse) *CheckHealthResult { func checkHealthResultFromProto(protoResp *pluginv2.CheckHealthResponse) *CheckHealthResult {
@@ -59,6 +59,23 @@ func checkHealthResultFromProto(protoResp *pluginv2.CheckHealthResponse) *CheckH
} }
} }
func collectMetricsResultFromProto(protoResp *pluginv2.CollectMetricsResponse) *CollectMetricsResult {
var prometheusMetrics []byte
if protoResp.Metrics != nil {
prometheusMetrics = protoResp.Metrics.Prometheus
}
return &CollectMetricsResult{
PrometheusMetrics: prometheusMetrics,
}
}
// CollectMetricsResult collect metrics result.
type CollectMetricsResult struct {
PrometheusMetrics []byte
}
type DataSourceConfig struct { type DataSourceConfig struct {
ID int64 ID int64
Name string Name string

View File

@@ -10,10 +10,7 @@ import (
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util/proxyutil" "github.com/grafana/grafana/pkg/util/proxyutil"
"github.com/prometheus/client_golang/prometheus"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins/backendplugin/collector"
"github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/registry"
plugin "github.com/hashicorp/go-plugin" plugin "github.com/hashicorp/go-plugin"
"golang.org/x/xerrors" "golang.org/x/xerrors"
@@ -42,24 +39,23 @@ type Manager interface {
Register(descriptor PluginDescriptor) error Register(descriptor PluginDescriptor) error
// StartPlugin starts a non-managed backend plugin // StartPlugin starts a non-managed backend plugin
StartPlugin(ctx context.Context, pluginID string) error StartPlugin(ctx context.Context, pluginID string) error
// CollectMetrics collects metrics from a registered backend plugin.
CollectMetrics(ctx context.Context, pluginID string) (*CollectMetricsResult, error)
// CheckHealth checks the health of a registered backend plugin. // CheckHealth checks the health of a registered backend plugin.
CheckHealth(ctx context.Context, pluginID string) (*CheckHealthResult, error) CheckHealth(ctx context.Context, pluginConfig *PluginConfig) (*CheckHealthResult, error)
// CallResource calls a plugin resource. // CallResource calls a plugin resource.
CallResource(pluginConfig PluginConfig, ctx *models.ReqContext, path string) CallResource(pluginConfig PluginConfig, ctx *models.ReqContext, path string)
} }
type manager struct { type manager struct {
pluginsMu sync.RWMutex pluginsMu sync.RWMutex
plugins map[string]*BackendPlugin plugins map[string]*BackendPlugin
pluginCollector collector.PluginCollector logger log.Logger
logger log.Logger
} }
func (m *manager) Init() error { func (m *manager) Init() error {
m.plugins = make(map[string]*BackendPlugin) m.plugins = make(map[string]*BackendPlugin)
m.logger = log.New("plugins.backend") m.logger = log.New("plugins.backend")
m.pluginCollector = collector.NewPluginCollector()
prometheus.MustRegister(m.pluginCollector)
return nil return nil
} }
@@ -111,11 +107,6 @@ func (m *manager) start(ctx context.Context) {
p.logger.Error("Failed to start plugin", "error", err) p.logger.Error("Failed to start plugin", "error", err)
continue continue
} }
if p.supportsDiagnostics() {
p.logger.Debug("Registering metrics collector")
m.pluginCollector.Register(p.id, p)
}
} }
} }
@@ -150,8 +141,8 @@ func (m *manager) stop() {
} }
} }
// CheckHealth checks the health of a registered backend plugin. // CollectMetrics collects metrics from a registered backend plugin.
func (m *manager) CheckHealth(ctx context.Context, pluginID string) (*CheckHealthResult, error) { func (m *manager) CollectMetrics(ctx context.Context, pluginID string) (*CollectMetricsResult, error) {
m.pluginsMu.RLock() m.pluginsMu.RLock()
p, registered := m.plugins[pluginID] p, registered := m.plugins[pluginID]
m.pluginsMu.RUnlock() m.pluginsMu.RUnlock()
@@ -164,7 +155,29 @@ func (m *manager) CheckHealth(ctx context.Context, pluginID string) (*CheckHealt
return nil, ErrDiagnosticsNotSupported return nil, ErrDiagnosticsNotSupported
} }
res, err := p.checkHealth(ctx) res, err := p.CollectMetrics(ctx)
if err != nil {
return nil, err
}
return collectMetricsResultFromProto(res), nil
}
// CheckHealth checks the health of a registered backend plugin.
func (m *manager) CheckHealth(ctx context.Context, pluginConfig *PluginConfig) (*CheckHealthResult, error) {
m.pluginsMu.RLock()
p, registered := m.plugins[pluginConfig.PluginID]
m.pluginsMu.RUnlock()
if !registered {
return nil, ErrPluginNotRegistered
}
if !p.supportsDiagnostics() {
return nil, ErrDiagnosticsNotSupported
}
res, err := p.checkHealth(ctx, pluginConfig)
if err != nil { if err != nil {
p.logger.Error("Failed to check plugin health", "error", err) p.logger.Error("Failed to check plugin health", "error", err)
return nil, ErrHealthCheckFailed return nil, ErrHealthCheckFailed

View File

@@ -188,6 +188,15 @@ func (pm *PluginManager) scan(pluginDir string) error {
return nil return nil
} }
// GetDatasource returns a datasource based on passed pluginID if it exists
//
// This function fetches the datasource from the global variable DataSources in this package.
// Rather then refactor all dependencies on the global variable we can use this as an transition.
func (pm *PluginManager) GetDatasource(pluginID string) (*DataSourcePlugin, bool) {
ds, exist := DataSources[pluginID]
return ds, exist
}
func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err error) error { func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err error) error {
// We scan all the subfolders for plugin.json (with some exceptions) so that we also load embedded plugins, for // We scan all the subfolders for plugin.json (with some exceptions) so that we also load embedded plugins, for
// example https://github.com/raintank/worldping-app/tree/master/dist/grafana-worldmap-panel worldmap panel plugin // example https://github.com/raintank/worldping-app/tree/master/dist/grafana-worldmap-panel worldmap panel plugin

View File

@@ -126,13 +126,15 @@ func (gcn *GoogleChatNotifier) Notify(evalContext *alerting.EvalContext) error {
gcn.log.Error("evalContext returned an invalid rule URL") gcn.log.Error("evalContext returned an invalid rule URL")
} }
// add a text paragraph widget for the message widgets := []widget{}
widgets := []widget{ if len(evalContext.Rule.Message) > 0 {
textParagraphWidget{ // add a text paragraph widget for the message if there is a message
// Google Chat API doesn't accept an empty text property
widgets = append(widgets, textParagraphWidget{
Text: text{ Text: text{
Text: evalContext.Rule.Message, Text: evalContext.Rule.Message,
}, },
}, })
} }
// add a text paragraph widget for the fields // add a text paragraph widget for the fields

View File

@@ -145,6 +145,10 @@ func addAnnotationMig(mg *Migrator) {
mg.AddMigration("Remove index org_id_epoch_epoch_end from annotation table", NewDropIndexMigration(table, &Index{ mg.AddMigration("Remove index org_id_epoch_epoch_end from annotation table", NewDropIndexMigration(table, &Index{
Cols: []string{"org_id", "epoch", "epoch_end"}, Type: IndexType, Cols: []string{"org_id", "epoch", "epoch_end"}, Type: IndexType,
})) }))
mg.AddMigration("Add index for alert_id on annotation table", NewAddIndexMigration(table, &Index{
Cols: []string{"alert_id"}, Type: IndexType,
}))
} }
type AddMakeRegionSingleRowMigration struct { type AddMakeRegionSingleRowMigration struct {

View File

@@ -12,6 +12,8 @@ export interface Props {
currentDashboard?: SelectableValue<number>; currentDashboard?: SelectableValue<number>;
size?: FormInputSize; size?: FormInputSize;
isClearable?: boolean; isClearable?: boolean;
invalid?: boolean;
disabled?: boolean;
} }
const getDashboards = (query = '') => { const getDashboards = (query = '') => {
@@ -24,7 +26,14 @@ const getDashboards = (query = '') => {
}); });
}; };
export const DashboardPicker: FC<Props> = ({ onSelected, currentDashboard, size = 'md', isClearable = false }) => { export const DashboardPicker: FC<Props> = ({
onSelected,
currentDashboard,
size = 'md',
isClearable = false,
invalid,
disabled,
}) => {
const debouncedSearch = debounce(getDashboards, 300, { const debouncedSearch = debounce(getDashboards, 300, {
leading: true, leading: true,
trailing: true, trailing: true,
@@ -43,6 +52,8 @@ export const DashboardPicker: FC<Props> = ({ onSelected, currentDashboard, size
placeholder="Select dashboard" placeholder="Select dashboard"
noOptionsMessage="No dashboards found" noOptionsMessage="No dashboards found"
value={currentDashboard} value={currentDashboard}
invalid={invalid}
disabled={disabled}
/> />
); );
}; };

View File

@@ -109,7 +109,7 @@ export class FolderPicker extends PureComponent<Props, State> {
let folder: SelectableValue<number> = { value: -1 }; let folder: SelectableValue<number> = { value: -1 };
if (initialFolderId !== undefined && initialFolderId > -1) { if (initialFolderId !== undefined && initialFolderId !== null && initialFolderId > -1) {
folder = options.find(option => option.value === initialFolderId) || { value: -1 }; folder = options.find(option => option.value === initialFolderId) || { value: -1 };
} else if (enableReset && initialTitle) { } else if (enableReset && initialTitle) {
folder = resetFolder; folder = resetFolder;

View File

@@ -1,6 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import coreModule from '../../core_module'; import coreModule from '../../core_module';
import { ISCEService } from 'angular'; import { ISCEService } from 'angular';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
function typeaheadMatcher(this: any, item: string) { function typeaheadMatcher(this: any, item: string) {
let str = this.query; let str = this.query;
@@ -101,8 +102,7 @@ export class FormDropdownCtrl {
} }
getOptionsInternal(query: string) { getOptionsInternal(query: string) {
const result = this.getOptions({ $query: query }); return promiseToDigest(this.$scope)(Promise.resolve(this.getOptions({ $query: query })));
return Promise.resolve(result);
} }
isPromiseLike(obj: any) { isPromiseLike(obj: any) {

View File

@@ -1,4 +1,3 @@
import omitBy from 'lodash/omitBy';
import { from, merge, MonoTypeOperatorFunction, Observable, of, Subject, throwError } from 'rxjs'; import { from, merge, MonoTypeOperatorFunction, Observable, of, Subject, throwError } from 'rxjs';
import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators'; import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators';
import { fromFetch } from 'rxjs/fetch'; import { fromFetch } from 'rxjs/fetch';
@@ -14,6 +13,7 @@ import { ContextSrv, contextSrv } from './context_srv';
import { coreModule } from 'app/core/core_module'; import { coreModule } from 'app/core/core_module';
import { Emitter } from '../utils/emitter'; import { Emitter } from '../utils/emitter';
import { DataSourceResponse } from '../../types/events'; import { DataSourceResponse } from '../../types/events';
import { parseInitFromOptions, parseUrlFromOptions } from '../utils/fetch';
export interface DatasourceRequestOptions { export interface DatasourceRequestOptions {
retry?: number; retry?: number;
@@ -54,18 +54,6 @@ enum CancellationType {
dataSourceRequest, dataSourceRequest,
} }
function serializeParams(data: Record<string, any>): string {
return Object.keys(data)
.map(key => {
const value = data[key];
if (Array.isArray(value)) {
return value.map(arrayValue => `${encodeURIComponent(key)}=${encodeURIComponent(arrayValue)}`).join('&');
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
}
export interface BackendSrvDependencies { export interface BackendSrvDependencies {
fromFetch: (input: string | Request, init?: RequestInit) => Observable<Response>; fromFetch: (input: string | Request, init?: RequestInit) => Observable<Response>;
appEvents: Emitter; appEvents: Emitter;
@@ -459,7 +447,7 @@ export class BackendSrv implements BackendService {
url, url,
type, type,
redirected, redirected,
request: { url, ...init }, config: options,
}; };
return fetchResponse; return fetchResponse;
}), }),
@@ -567,7 +555,7 @@ export class BackendSrv implements BackendService {
data: [], data: [],
status: this.HTTP_REQUEST_CANCELED, status: this.HTTP_REQUEST_CANCELED,
statusText: 'Request was aborted', statusText: 'Request was aborted',
request: { url: parseUrlFromOptions(options), ...parseInitFromOptions(options) }, config: options,
}); });
} }
@@ -580,36 +568,3 @@ coreModule.factory('backendSrv', () => backendSrv);
// Used for testing and things that really need BackendSrv // Used for testing and things that really need BackendSrv
export const backendSrv = new BackendSrv(); export const backendSrv = new BackendSrv();
export const getBackendSrv = (): BackendSrv => backendSrv; export const getBackendSrv = (): BackendSrv => backendSrv;
export const parseUrlFromOptions = (options: BackendSrvRequest): string => {
const cleanParams = omitBy(options.params, v => v === undefined || (v && v.length === 0));
const serializedParams = serializeParams(cleanParams);
return options.params && serializedParams.length ? `${options.url}?${serializedParams}` : options.url;
};
export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit => {
const method = options.method;
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
...options.headers,
};
const body = parseBody({ ...options, headers });
return {
method,
headers,
body,
};
};
const parseBody = (options: BackendSrvRequest) => {
if (!options.data || typeof options.data === 'string') {
return options.data;
}
if (options.headers['Content-Type'] === 'application/json') {
return JSON.stringify(options.data);
}
return new URLSearchParams(options.data);
};

View File

@@ -1,10 +1,12 @@
import { BackendSrv, getBackendSrv, parseInitFromOptions, parseUrlFromOptions } from '../services/backend_srv'; import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { AppEvents } from '@grafana/data';
import { BackendSrv, getBackendSrv } from '../services/backend_srv';
import { Emitter } from '../utils/emitter'; import { Emitter } from '../utils/emitter';
import { ContextSrv, User } from '../services/context_srv'; import { ContextSrv, User } from '../services/context_srv';
import { Observable, of } from 'rxjs';
import { AppEvents } from '@grafana/data';
import { CoreEvents } from '../../types'; import { CoreEvents } from '../../types';
import { delay } from 'rxjs/operators';
const getTestContext = (overides?: object) => { const getTestContext = (overides?: object) => {
const defaults = { const defaults = {
@@ -17,7 +19,6 @@ const getTestContext = (overides?: object) => {
redirected: false, redirected: false,
type: 'basic', type: 'basic',
url: 'http://localhost:3000/api/some-mock', url: 'http://localhost:3000/api/some-mock',
headers: { 'Content-Type': 'application/json' },
}; };
const props = { ...defaults, ...overides }; const props = { ...defaults, ...overides };
const textMock = jest.fn().mockResolvedValue(JSON.stringify(props.data)); const textMock = jest.fn().mockResolvedValue(JSON.stringify(props.data));
@@ -30,7 +31,6 @@ const getTestContext = (overides?: object) => {
redirected: false, redirected: false,
type: 'basic', type: 'basic',
url: 'http://localhost:3000/api/some-mock', url: 'http://localhost:3000/api/some-mock',
headers: { 'Content-Type': 'application/json' },
}; };
return of(mockedResponse); return of(mockedResponse);
}); });
@@ -174,7 +174,9 @@ describe('backendSrv', () => {
statusText: 'Ok', statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(slowData)), text: () => Promise.resolve(JSON.stringify(slowData)),
headers: { headers: {
'Content-Type': 'application/json', map: {
'content-type': 'application/json',
},
}, },
redirected: false, redirected: false,
type: 'basic', type: 'basic',
@@ -189,7 +191,9 @@ describe('backendSrv', () => {
statusText: 'Ok', statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(fastData)), text: () => Promise.resolve(JSON.stringify(fastData)),
headers: { headers: {
'Content-Type': 'application/json', map: {
'content-type': 'application/json',
},
}, },
redirected: false, redirected: false,
type: 'basic', type: 'basic',
@@ -341,27 +345,17 @@ describe('backendSrv', () => {
it('then it should not emit message', async () => { it('then it should not emit message', async () => {
const url = 'http://localhost:3000/api/some-mock'; const url = 'http://localhost:3000/api/some-mock';
const { backendSrv, appEventsMock, expectDataSourceRequestCallChain } = getTestContext({ url }); const { backendSrv, appEventsMock, expectDataSourceRequestCallChain } = getTestContext({ url });
const result = await backendSrv.datasourceRequest({ url, method: 'GET', silent: true }); const options = { url, method: 'GET', silent: true };
const result = await backendSrv.datasourceRequest(options);
expect(result).toEqual({ expect(result).toEqual({
data: { test: 'hello world' }, data: { test: 'hello world' },
headers: {
'Content-Type': 'application/json',
},
ok: true, ok: true,
redirected: false, redirected: false,
status: 200, status: 200,
statusText: 'Ok', statusText: 'Ok',
type: 'basic', type: 'basic',
url, url,
request: { config: options,
url,
method: 'GET',
body: undefined,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
},
},
}); });
expect(appEventsMock.emit).not.toHaveBeenCalled(); expect(appEventsMock.emit).not.toHaveBeenCalled();
expectDataSourceRequestCallChain({ url, method: 'GET', silent: true }); expectDataSourceRequestCallChain({ url, method: 'GET', silent: true });
@@ -372,27 +366,17 @@ describe('backendSrv', () => {
it('then it should not emit message', async () => { it('then it should not emit message', async () => {
const url = 'http://localhost:3000/api/some-mock'; const url = 'http://localhost:3000/api/some-mock';
const { backendSrv, appEventsMock, expectDataSourceRequestCallChain } = getTestContext({ url }); const { backendSrv, appEventsMock, expectDataSourceRequestCallChain } = getTestContext({ url });
const result = await backendSrv.datasourceRequest({ url, method: 'GET' }); const options = { url, method: 'GET' };
const result = await backendSrv.datasourceRequest(options);
const expectedResult = { const expectedResult = {
data: { test: 'hello world' }, data: { test: 'hello world' },
headers: {
'Content-Type': 'application/json',
},
ok: true, ok: true,
redirected: false, redirected: false,
status: 200, status: 200,
statusText: 'Ok', statusText: 'Ok',
type: 'basic', type: 'basic',
url, url,
request: { config: options,
url,
method: 'GET',
body: undefined as any,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
},
},
}; };
expect(result).toEqual(expectedResult); expect(result).toEqual(expectedResult);
@@ -414,9 +398,6 @@ describe('backendSrv', () => {
status: 200, status: 200,
statusText: 'Ok', statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(slowData)), text: () => Promise.resolve(JSON.stringify(slowData)),
headers: {
'Content-Type': 'application/json',
},
redirected: false, redirected: false,
type: 'basic', type: 'basic',
url, url,
@@ -429,9 +410,6 @@ describe('backendSrv', () => {
status: 200, status: 200,
statusText: 'Ok', statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(fastData)), text: () => Promise.resolve(JSON.stringify(fastData)),
headers: {
'Content-Type': 'application/json',
},
redirected: false, redirected: false,
type: 'basic', type: 'basic',
url, url,
@@ -447,24 +425,13 @@ describe('backendSrv', () => {
const fastResponse = await backendSrv.datasourceRequest(options); const fastResponse = await backendSrv.datasourceRequest(options);
expect(fastResponse).toEqual({ expect(fastResponse).toEqual({
data: { message: 'Fast Request' }, data: { message: 'Fast Request' },
headers: {
'Content-Type': 'application/json',
},
ok: true, ok: true,
redirected: false, redirected: false,
status: 200, status: 200,
statusText: 'Ok', statusText: 'Ok',
type: 'basic', type: 'basic',
url: '/api/dashboard/', url: '/api/dashboard/',
request: { config: options,
url: '/api/dashboard/',
method: 'GET',
body: undefined,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
},
},
}); });
const result = await slowRequest; const result = await slowRequest;
@@ -472,15 +439,7 @@ describe('backendSrv', () => {
data: [], data: [],
status: -1, status: -1,
statusText: 'Request was aborted', statusText: 'Request was aborted',
request: { config: options,
url: '/api/dashboard/',
method: 'GET',
body: undefined,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
},
},
}); });
expect(unsubscribe).toHaveBeenCalledTimes(1); expect(unsubscribe).toHaveBeenCalledTimes(1);
}); });
@@ -613,43 +572,3 @@ describe('backendSrv', () => {
}); });
}); });
}); });
describe('parseUrlFromOptions', () => {
it.each`
params | url | expected
${undefined} | ${'api/dashboard'} | ${'api/dashboard'}
${{ key: 'value' }} | ${'api/dashboard'} | ${'api/dashboard?key=value'}
${{ key: undefined }} | ${'api/dashboard'} | ${'api/dashboard'}
${{ firstKey: 'first value', secondValue: 'second value' }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value&secondValue=second%20value'}
${{ firstKey: 'first value', secondValue: undefined }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value'}
${{ id: [1, 2, 3] }} | ${'api/dashboard'} | ${'api/dashboard?id=1&id=2&id=3'}
${{ id: [] }} | ${'api/dashboard'} | ${'api/dashboard'}
`(
"when called with params: '$params' and url: '$url' then result should be '$expected'",
({ params, url, expected }) => {
expect(parseUrlFromOptions({ params, url })).toEqual(expected);
}
);
});
describe('parseInitFromOptions', () => {
it.each`
method | headers | data | expected
${undefined} | ${undefined} | ${undefined} | ${{ method: undefined, headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*' }, body: undefined }}
${'GET'} | ${undefined} | ${undefined} | ${{ method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*' }, body: undefined }}
${'GET'} | ${undefined} | ${null} | ${{ method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*' }, body: null }}
${'GET'} | ${{ Auth: 'Some Auth' }} | ${undefined} | ${{ method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*', Auth: 'Some Auth' }, body: undefined }}
${'GET'} | ${{ Auth: 'Some Auth' }} | ${{ data: { test: 'Some data' } }} | ${{ method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*', Auth: 'Some Auth' }, body: '{"data":{"test":"Some data"}}' }}
${'GET'} | ${{ Auth: 'Some Auth' }} | ${'some data'} | ${{ method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*', Auth: 'Some Auth' }, body: 'some data' }}
${'GET'} | ${{ Auth: 'Some Auth' }} | ${'{"data":{"test":"Some data"}}'} | ${{ method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*', Auth: 'Some Auth' }, body: '{"data":{"test":"Some data"}}' }}
${'POST'} | ${{ Auth: 'Some Auth', 'Content-Type': 'application/x-www-form-urlencoded' }} | ${undefined} | ${{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json, text/plain, */*', Auth: 'Some Auth' }, body: undefined }}
${'POST'} | ${{ Auth: 'Some Auth', 'Content-Type': 'application/x-www-form-urlencoded' }} | ${{ data: 'Some data' }} | ${{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json, text/plain, */*', Auth: 'Some Auth' }, body: new URLSearchParams({ data: 'Some data' }) }}
${'POST'} | ${{ Auth: 'Some Auth', 'Content-Type': 'application/x-www-form-urlencoded' }} | ${'some data'} | ${{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json, text/plain, */*', Auth: 'Some Auth' }, body: 'some data' }}
${'POST'} | ${{ Auth: 'Some Auth', 'Content-Type': 'application/x-www-form-urlencoded' }} | ${'{"data":{"test":"Some data"}}'} | ${{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json, text/plain, */*', Auth: 'Some Auth' }, body: '{"data":{"test":"Some data"}}' }}
`(
"when called with method: '$method', headers: '$headers' and data: '$data' then result should be '$expected'",
({ method, headers, data, expected }) => {
expect(parseInitFromOptions({ method, headers, data, url: '' })).toEqual(expected);
}
);
});

View File

@@ -0,0 +1,102 @@
import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering
import {
isContentTypeApplicationJson,
parseBody,
parseHeaders,
parseInitFromOptions,
parseUrlFromOptions,
} from './fetch';
describe('parseUrlFromOptions', () => {
it.each`
params | url | expected
${undefined} | ${'api/dashboard'} | ${'api/dashboard'}
${{ key: 'value' }} | ${'api/dashboard'} | ${'api/dashboard?key=value'}
${{ key: undefined }} | ${'api/dashboard'} | ${'api/dashboard'}
${{ firstKey: 'first value', secondValue: 'second value' }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value&secondValue=second%20value'}
${{ firstKey: 'first value', secondValue: undefined }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value'}
${{ id: [1, 2, 3] }} | ${'api/dashboard'} | ${'api/dashboard?id=1&id=2&id=3'}
${{ id: [] }} | ${'api/dashboard'} | ${'api/dashboard'}
`(
"when called with params: '$params' and url: '$url' then result should be '$expected'",
({ params, url, expected }) => {
expect(parseUrlFromOptions({ params, url })).toEqual(expected);
}
);
});
describe('parseInitFromOptions', () => {
it.each`
method | data | withCredentials | expected
${undefined} | ${undefined} | ${undefined} | ${{ method: undefined, headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
${'GET'} | ${undefined} | ${undefined} | ${{ method: 'GET', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
${'POST'} | ${{ id: '0' }} | ${undefined} | ${{ method: 'POST', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
${'PUT'} | ${{ id: '0' }} | ${undefined} | ${{ method: 'PUT', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }}
${'monkey'} | ${undefined} | ${undefined} | ${{ method: 'monkey', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }}
${'GET'} | ${undefined} | ${true} | ${{ method: 'GET', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined, credentials: 'include' }}
`(
"when called with method: '$method', data: '$data' and withCredentials: '$withCredentials' then result should be '$expected'",
({ method, data, withCredentials, expected }) => {
expect(parseInitFromOptions({ method, data, withCredentials, url: '' })).toEqual(expected);
}
);
});
describe('parseHeaders', () => {
it.each`
options | expected
${undefined} | ${{ map: { accept: 'application/json, text/plain, */*' } }}
${{ propKey: 'some prop value' }} | ${{ map: { accept: 'application/json, text/plain, */*' } }}
${{ method: 'GET' }} | ${{ map: { accept: 'application/json, text/plain, */*' } }}
${{ method: 'POST' }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ method: 'PUT' }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ method: 'GET', headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ method: 'POST', headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ method: 'PUT', headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ headers: { 'cOnTent-tYpe': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }}
${{ headers: { 'content-type': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }}
${{ headers: { 'cOnTent-tYpe': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }}
${{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ method: 'GET', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }}
${{ headers: { Accept: 'text/plain' } }} | ${{ map: { accept: 'text/plain' } }}
${{ headers: { Auth: 'Basic asdasdasd' } }} | ${{ map: { accept: 'application/json, text/plain, */*', auth: 'Basic asdasdasd' } }}
`("when called with options: '$options' then the result should be '$expected'", ({ options, expected }) => {
expect(parseHeaders(options)).toEqual(expected);
});
});
describe('isContentTypeApplicationJson', () => {
it.each`
headers | expected
${undefined} | ${false}
${new Headers({ 'cOnTent-tYpe': 'application/json' })} | ${true}
${new Headers({ 'content-type': 'AppLiCatIon/JsOn' })} | ${true}
${new Headers({ 'cOnTent-tYpe': 'AppLiCatIon/JsOn' })} | ${true}
${new Headers({ 'content-type': 'application/x-www-form-urlencoded' })} | ${false}
${new Headers({ auth: 'Basic akdjasdkjalksdjasd' })} | ${false}
`("when called with headers: 'headers' then the result should be '$expected'", ({ headers, expected }) => {
expect(isContentTypeApplicationJson(headers)).toEqual(expected);
});
});
describe('parseBody', () => {
it.each`
options | isAppJson | expected
${undefined} | ${false} | ${undefined}
${undefined} | ${true} | ${undefined}
${{ data: undefined }} | ${false} | ${undefined}
${{ data: undefined }} | ${true} | ${undefined}
${{ data: 'some data' }} | ${false} | ${'some data'}
${{ data: 'some data' }} | ${true} | ${'some data'}
${{ data: { id: '0' } }} | ${false} | ${new URLSearchParams({ id: '0' })}
${{ data: { id: '0' } }} | ${true} | ${'{"id":"0"}'}
`(
"when called with options: '$options' and isAppJson: '$isAppJson' then the result should be '$expected'",
({ options, isAppJson, expected }) => {
expect(parseBody(options, isAppJson)).toEqual(expected);
}
);
});

View File

@@ -0,0 +1,116 @@
import { BackendSrvRequest } from '@grafana/runtime';
import omitBy from 'lodash/omitBy';
export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit => {
const method = options.method;
const headers = parseHeaders(options);
const isAppJson = isContentTypeApplicationJson(headers);
const body = parseBody(options, isAppJson);
if (options?.withCredentials) {
return {
method,
headers,
body,
credentials: 'include',
};
}
return {
method,
headers,
body,
};
};
interface HeaderParser {
canParse: (options: BackendSrvRequest) => boolean;
parse: (headers: Headers) => Headers;
}
const defaultHeaderParser: HeaderParser = {
canParse: () => true,
parse: headers => {
const accept = headers.get('accept');
if (accept) {
return headers;
}
headers.set('accept', 'application/json, text/plain, */*');
return headers;
},
};
const parseHeaderByMethodFactory = (methodPredicate: string): HeaderParser => ({
canParse: options => {
const method = options?.method ? options?.method.toLowerCase() : '';
return method === methodPredicate;
},
parse: headers => {
const contentType = headers.get('content-type');
if (contentType) {
return headers;
}
headers.set('content-type', 'application/json');
return headers;
},
});
const postHeaderParser: HeaderParser = parseHeaderByMethodFactory('post');
const putHeaderParser: HeaderParser = parseHeaderByMethodFactory('put');
const headerParsers = [postHeaderParser, putHeaderParser, defaultHeaderParser];
export const parseHeaders = (options: BackendSrvRequest) => {
const headers = options?.headers ? new Headers(options.headers) : new Headers();
const parsers = headerParsers.filter(parser => parser.canParse(options));
const combinedHeaders = parsers.reduce((prev, parser) => {
return parser.parse(prev);
}, headers);
return combinedHeaders;
};
export const isContentTypeApplicationJson = (headers: Headers) => {
if (!headers) {
return false;
}
const contentType = headers.get('content-type');
if (contentType && contentType.toLowerCase() === 'application/json') {
return true;
}
return false;
};
export const parseBody = (options: BackendSrvRequest, isAppJson: boolean) => {
if (!options) {
return options;
}
if (!options.data || typeof options.data === 'string') {
return options.data;
}
return isAppJson ? JSON.stringify(options.data) : new URLSearchParams(options.data);
};
function serializeParams(data: Record<string, any>): string {
return Object.keys(data)
.map(key => {
const value = data[key];
if (Array.isArray(value)) {
return value.map(arrayValue => `${encodeURIComponent(key)}=${encodeURIComponent(arrayValue)}`).join('&');
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
}
export const parseUrlFromOptions = (options: BackendSrvRequest): string => {
const cleanParams = omitBy(options.params, v => v === undefined || (v && v.length === 0));
const serializedParams = serializeParams(cleanParams);
return options.params && serializedParams.length ? `${options.url}?${serializedParams}` : options.url;
};

View File

@@ -0,0 +1,181 @@
import {
addToRichHistory,
updateStarredInRichHistory,
updateCommentInRichHistory,
mapNumbertoTimeInSlider,
createDateStringFromTs,
createQueryHeading,
createDataQuery,
deleteAllFromRichHistory,
deleteQueryInRichHistory,
} from './richHistory';
import store from 'app/core/store';
import { SortOrder } from './explore';
const mock: any = {
history: [
{
comment: '',
datasourceId: 'datasource historyId',
datasourceName: 'datasource history name',
queries: ['query1', 'query2'],
sessionName: '',
starred: true,
ts: 1,
},
],
comment: '',
datasourceId: 'datasourceId',
datasourceName: 'datasourceName',
queries: ['query3'],
sessionName: '',
starred: false,
};
const key = 'grafana.explore.richHistory';
describe('addToRichHistory', () => {
beforeEach(() => {
deleteAllFromRichHistory();
expect(store.exists(key)).toBeFalsy();
});
const expectedResult = [
{
comment: mock.comment,
datasourceId: mock.datasourceId,
datasourceName: mock.datasourceName,
queries: mock.queries,
sessionName: mock.sessionName,
starred: mock.starred,
ts: 2,
},
mock.history[0],
];
it('should append query to query history', () => {
Date.now = jest.fn(() => 2);
const newHistory = addToRichHistory(
mock.history,
mock.datasourceId,
mock.datasourceName,
mock.queries,
mock.starred,
mock.comment,
mock.sessionName
);
expect(newHistory).toEqual(expectedResult);
});
it('should save query history to localStorage', () => {
Date.now = jest.fn(() => 2);
addToRichHistory(
mock.history,
mock.datasourceId,
mock.datasourceName,
mock.queries,
mock.starred,
mock.comment,
mock.sessionName
);
expect(store.exists(key)).toBeTruthy();
expect(store.getObject(key)).toMatchObject(expectedResult);
});
it('should not append duplicated query to query history', () => {
Date.now = jest.fn(() => 2);
const newHistory = addToRichHistory(
mock.history,
mock.history[0].datasourceId,
mock.history[0].datasourceName,
mock.history[0].queries,
mock.starred,
mock.comment,
mock.sessionName
);
expect(newHistory).toEqual([mock.history[0]]);
});
it('should not save duplicated query to localStorage', () => {
Date.now = jest.fn(() => 2);
addToRichHistory(
mock.history,
mock.history[0].datasourceId,
mock.history[0].datasourceName,
mock.history[0].queries,
mock.starred,
mock.comment,
mock.sessionName
);
expect(store.exists(key)).toBeFalsy();
});
});
describe('updateStarredInRichHistory', () => {
it('should update starred in query in history', () => {
const updatedStarred = updateStarredInRichHistory(mock.history, 1);
expect(updatedStarred[0].starred).toEqual(false);
});
it('should update starred in localStorage', () => {
updateStarredInRichHistory(mock.history, 1);
expect(store.exists(key)).toBeTruthy();
expect(store.getObject(key)[0].starred).toEqual(false);
});
});
describe('updateCommentInRichHistory', () => {
it('should update comment in query in history', () => {
const updatedComment = updateCommentInRichHistory(mock.history, 1, 'new comment');
expect(updatedComment[0].comment).toEqual('new comment');
});
it('should update comment in localStorage', () => {
updateCommentInRichHistory(mock.history, 1, 'new comment');
expect(store.exists(key)).toBeTruthy();
expect(store.getObject(key)[0].comment).toEqual('new comment');
});
});
describe('deleteQueryInRichHistory', () => {
it('should delete query in query in history', () => {
const deletedHistory = deleteQueryInRichHistory(mock.history, 1);
expect(deletedHistory).toEqual([]);
});
it('should delete query in localStorage', () => {
deleteQueryInRichHistory(mock.history, 1);
expect(store.exists(key)).toBeTruthy();
expect(store.getObject(key)).toEqual([]);
});
});
describe('mapNumbertoTimeInSlider', () => {
it('should correctly map number to value', () => {
const value = mapNumbertoTimeInSlider(25);
expect(value).toEqual('25 days ago');
});
});
describe('createDateStringFromTs', () => {
it('should correctly create string value from timestamp', () => {
const value = createDateStringFromTs(1583932327000);
expect(value).toEqual('March 11');
});
});
describe('createQueryHeading', () => {
it('should correctly create heading for queries when sort order is ascending ', () => {
const heading = createQueryHeading(mock.history[0], SortOrder.Ascending);
expect(heading).toEqual('January 1');
});
it('should correctly create heading for queries when sort order is datasourceAZ ', () => {
const heading = createQueryHeading(mock.history[0], SortOrder.DatasourceAZ);
expect(heading).toEqual(mock.history[0].datasourceName);
});
});
describe('createDataQuery', () => {
it('should correctly create data query from rich history query', () => {
const dataQuery = createDataQuery(mock.history[0], mock.queries[0], 0);
expect(dataQuery).toEqual({ datasource: 'datasource history name', expr: 'query3', refId: 'A' });
});
});

View File

@@ -6,6 +6,7 @@ import { DataQuery, ExploreMode } from '@grafana/data';
import { renderUrl } from 'app/core/utils/url'; import { renderUrl } from 'app/core/utils/url';
import store from 'app/core/store'; import store from 'app/core/store';
import { serializeStateToUrlParam, SortOrder } from './explore'; import { serializeStateToUrlParam, SortOrder } from './explore';
import { getExploreDatasources } from '../../features/explore/state/selectors';
// Types // Types
import { ExploreUrlState, RichHistoryQuery } from 'app/types/explore'; import { ExploreUrlState, RichHistoryQuery } from 'app/types/explore';
@@ -13,9 +14,10 @@ import { ExploreUrlState, RichHistoryQuery } from 'app/types/explore';
const RICH_HISTORY_KEY = 'grafana.explore.richHistory'; const RICH_HISTORY_KEY = 'grafana.explore.richHistory';
export const RICH_HISTORY_SETTING_KEYS = { export const RICH_HISTORY_SETTING_KEYS = {
retentionPeriod: `${RICH_HISTORY_KEY}.retentionPeriod`, retentionPeriod: 'grafana.explore.richHistory.retentionPeriod',
starredTabAsFirstTab: `${RICH_HISTORY_KEY}.starredTabAsFirstTab`, starredTabAsFirstTab: 'grafana.explore.richHistory.starredTabAsFirstTab',
activeDatasourceOnly: `${RICH_HISTORY_KEY}.activeDatasourceOnly`, activeDatasourceOnly: 'grafana.explore.richHistory.activeDatasourceOnly',
datasourceFilters: 'grafana.explore.richHistory.datasourceFilters',
}; };
/* /*
@@ -60,8 +62,14 @@ export function addToRichHistory(
]; ];
/* Combine all queries of a datasource type into one rich history */ /* Combine all queries of a datasource type into one rich history */
store.setObject(RICH_HISTORY_KEY, newHistory); const isSaved = store.setObject(RICH_HISTORY_KEY, newHistory);
return newHistory;
/* If newHistory is succesfully saved, return it. Otherwise return not updated richHistory. */
if (isSaved) {
return newHistory;
} else {
return richHistory;
}
} }
return richHistory; return richHistory;
@@ -107,6 +115,12 @@ export function updateCommentInRichHistory(
return updatedQueries; return updatedQueries;
} }
export function deleteQueryInRichHistory(richHistory: RichHistoryQuery[], ts: number) {
const updatedQueries = richHistory.filter(query => query.ts !== ts);
store.setObject(RICH_HISTORY_KEY, updatedQueries);
return updatedQueries;
}
export const sortQueries = (array: RichHistoryQuery[], sortOrder: SortOrder) => { export const sortQueries = (array: RichHistoryQuery[], sortOrder: SortOrder) => {
let sortFunc; let sortFunc;
@@ -251,3 +265,31 @@ export function mapQueriesToHeadings(query: RichHistoryQuery[], sortOrder: SortO
return mappedQueriesToHeadings; return mappedQueriesToHeadings;
} }
/* Create datasource list with images. If specific datasource retrieved from Rich history is not part of
* exploreDatasources add generic datasource image and add property isRemoved = true.
*/
export function createDatasourcesList(queriesDatasources: string[]) {
const exploreDatasources = getExploreDatasources();
const datasources: Array<{ label: string; value: string; imgUrl: string; isRemoved: boolean }> = [];
queriesDatasources.forEach(queryDsName => {
const index = exploreDatasources.findIndex(exploreDs => exploreDs.name === queryDsName);
if (index !== -1) {
datasources.push({
label: queryDsName,
value: queryDsName,
imgUrl: exploreDatasources[index].meta.info.logos.small,
isRemoved: false,
});
} else {
datasources.push({
label: queryDsName,
value: queryDsName,
imgUrl: 'public/img/icn-datasource.svg',
isRemoved: true,
});
}
});
return datasources;
}

View File

@@ -82,7 +82,8 @@ export class UserProfile extends PureComponent<Props, State> {
render() { render() {
const { user } = this.props; const { user } = this.props;
const { showDeleteModal, showDisableModal } = this.state; const { showDeleteModal, showDisableModal } = this.state;
const lockMessage = 'Synced via LDAP'; const authSource = user.authLabels?.length && user.authLabels[0];
const lockMessage = authSource ? `Synced via ${authSource}` : '';
const styles = getStyles(config.theme); const styles = getStyles(config.theme);
return ( return (

View File

@@ -72,7 +72,7 @@ export function annotationTooltipDirective(
tooltip += '<div class="graph-annotation__body">'; tooltip += '<div class="graph-annotation__body">';
if (text) { if (text) {
tooltip += '<div>' + sanitizeString(text.replace(/\n/g, '<br>')) + '</div>'; tooltip += '<div ng-non-bindable>' + sanitizeString(text.replace(/\n/g, '<br>')) + '</div>';
} }
const tags = event.tags; const tags = event.tags;

View File

@@ -17,6 +17,7 @@ import { DashboardModel } from '../../state';
import { CoreEvents, StoreState } from 'app/types'; import { CoreEvents, StoreState } from 'app/types';
import { ShareModal } from 'app/features/dashboard/components/ShareModal'; import { ShareModal } from 'app/features/dashboard/components/ShareModal';
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy'; import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
import { sanitizeUrl } from 'app/core/utils/text';
export interface OwnProps { export interface OwnProps {
dashboard: DashboardModel; dashboard: DashboardModel;
@@ -151,6 +152,7 @@ export class DashNav extends PureComponent<Props> {
const { canStar, canSave, canShare, showSettings, isStarred } = dashboard.meta; const { canStar, canSave, canShare, showSettings, isStarred } = dashboard.meta;
const { snapshot } = dashboard; const { snapshot } = dashboard;
const snapshotUrl = snapshot && snapshot.originalUrl; const snapshotUrl = snapshot && snapshot.originalUrl;
return ( return (
<div className="navbar"> <div className="navbar">
{this.isInFullscreenOrSettings && this.renderBackButton()} {this.isInFullscreenOrSettings && this.renderBackButton()}
@@ -239,7 +241,7 @@ export class DashNav extends PureComponent<Props> {
tooltip="Open original dashboard" tooltip="Open original dashboard"
classSuffix="snapshot-origin" classSuffix="snapshot-origin"
icon="gicon gicon-link" icon="gicon gicon-link"
href={snapshotUrl} href={sanitizeUrl(snapshotUrl)}
/> />
)} )}

View File

@@ -7,7 +7,7 @@ import { SaveDashboardModal } from './SaveDashboardModal';
export const SaveDashboardModalProxy: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => { export const SaveDashboardModalProxy: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
const isProvisioned = dashboard.meta.provisioned; const isProvisioned = dashboard.meta.provisioned;
const isNew = dashboard.title === NEW_DASHBOARD_DEFAULT_TITLE; const isNew = dashboard.title === NEW_DASHBOARD_DEFAULT_TITLE || dashboard.version === 0;
const isChanged = dashboard.version > 0; const isChanged = dashboard.version > 0;
const modalProps = { const modalProps = {

View File

@@ -75,7 +75,12 @@ export const SaveDashboardAsForm: React.FC<SaveDashboardFormProps & { isNew?: bo
{({ register, control, errors }) => ( {({ register, control, errors }) => (
<> <>
<Forms.Field label="Dashboard name" invalid={!!errors.title} error="Dashboard name is required"> <Forms.Field label="Dashboard name" invalid={!!errors.title} error="Dashboard name is required">
<Forms.Input name="title" ref={register({ required: true })} aria-label="Save dashboard title field" /> <Forms.Input
name="title"
ref={register({ required: true })}
aria-label="Save dashboard title field"
autoFocus
/>
</Forms.Field> </Forms.Field>
<Forms.Field label="Folder"> <Forms.Field label="Folder">
<Forms.InputControl <Forms.InputControl

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Forms, Button, HorizontalGroup } from '@grafana/ui'; import { Forms, Button, HorizontalGroup } from '@grafana/ui';
import { e2e } from '@grafana/e2e'; import { e2e } from '@grafana/e2e';
import { SaveDashboardFormProps } from '../types'; import { SaveDashboardFormProps } from '../types';
@@ -30,27 +31,32 @@ export const SaveDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard,
> >
{({ register, errors }) => ( {({ register, errors }) => (
<> <>
<Forms.Field label="Changes description"> <div className="gf-form-group">
<Forms.TextArea name="message" ref={register} placeholder="Add a note to describe your changes..." /> {hasTimeChanged && (
</Forms.Field> <Forms.Checkbox
{hasTimeChanged && ( label="Save current time range as dashboard default"
<Forms.Field label="Save current time range" description="Dashboard time range has changed">
<Forms.Switch
name="saveTimerange" name="saveTimerange"
ref={register} ref={register}
aria-label={e2e.pages.SaveDashboardModal.selectors.saveTimerange} aria-label={e2e.pages.SaveDashboardModal.selectors.saveTimerange}
/> />
</Forms.Field> )}
)} {hasVariableChanged && (
{hasVariableChanged && ( <Forms.Checkbox
<Forms.Field label="Save current variables" description="Dashboard variables have changed"> label="Save current variable values as dashboard default"
<Forms.Switch
name="saveVariables" name="saveVariables"
ref={register} ref={register}
aria-label={e2e.pages.SaveDashboardModal.selectors.saveVariables} aria-label={e2e.pages.SaveDashboardModal.selectors.saveVariables}
/> />
</Forms.Field> )}
)} {(hasVariableChanged || hasTimeChanged) && <div className="gf-form-group" />}
<Forms.TextArea
name="message"
ref={register}
placeholder="Add a note to describe your changes..."
autoFocus
/>
</div>
<HorizontalGroup> <HorizontalGroup>
<Button type="submit" aria-label={e2e.pages.SaveDashboardModal.selectors.save}> <Button type="submit" aria-label={e2e.pages.SaveDashboardModal.selectors.save}>

View File

@@ -72,10 +72,12 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
}; };
renderPanel(plugin: PanelPlugin) { renderPanel(plugin: PanelPlugin) {
const { dashboard, panel, isFullscreen, isInView, isInEditMode } = this.props; const { dashboard, panel, isFullscreen, isEditing, isInView, isInEditMode } = this.props;
const autoSizerStyle = { height: isEditing ? '100%' : '' };
return ( return (
<AutoSizer> <AutoSizer style={autoSizerStyle}>
{({ width, height }) => { {({ width, height }) => {
if (width === 0) { if (width === 0) {
return null; return null;

View File

@@ -11,11 +11,13 @@ import { PanelHeader } from './PanelHeader/PanelHeader';
import { getTimeSrv, TimeSrv } from '../services/TimeSrv'; import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
import { getAngularLoader, AngularComponent } from '@grafana/runtime'; import { getAngularLoader, AngularComponent } from '@grafana/runtime';
import { setPanelAngularComponent } from '../state/reducers'; import { setPanelAngularComponent } from '../state/reducers';
import config from 'app/core/config';
// Types // Types
import { DashboardModel, PanelModel } from '../state'; import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { LoadingState, DefaultTimeRange, PanelData, PanelPlugin, PanelEvents } from '@grafana/data'; import { LoadingState, DefaultTimeRange, PanelData, PanelPlugin, PanelEvents } from '@grafana/data';
import { PANEL_BORDER } from 'app/core/constants';
interface OwnProps { interface OwnProps {
panel: PanelModel; panel: PanelModel;
@@ -135,15 +137,32 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
if (prevProps.width !== width || prevProps.height !== height) { if (prevProps.width !== width || prevProps.height !== height) {
if (this.scopeProps) { if (this.scopeProps) {
this.scopeProps.size.height = height; this.scopeProps.size.height = this.getInnerPanelHeight();
this.scopeProps.size.width = width; this.scopeProps.size.width = this.getInnerPanelWidth();
panel.events.emit(PanelEvents.panelSizeChanged); panel.events.emit(PanelEvents.panelSizeChanged);
} }
} }
} }
getInnerPanelHeight() {
const { plugin, height } = this.props;
const { theme } = config;
const headerHeight = this.hasOverlayHeader() ? 0 : theme.panelHeaderHeight;
const chromePadding = plugin.noPadding ? 0 : theme.panelPadding;
return height - headerHeight - chromePadding * 2 - PANEL_BORDER;
}
getInnerPanelWidth() {
const { plugin, width } = this.props;
const { theme } = config;
const chromePadding = plugin.noPadding ? 0 : theme.panelPadding;
return width - chromePadding * 2 - PANEL_BORDER;
}
loadAngularPanel() { loadAngularPanel() {
const { panel, dashboard, height, width, setPanelAngularComponent } = this.props; const { panel, dashboard, setPanelAngularComponent } = this.props;
// if we have no element or already have loaded the panel return // if we have no element or already have loaded the panel return
if (!this.element) { if (!this.element) {
@@ -156,7 +175,7 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
this.scopeProps = { this.scopeProps = {
panel: panel, panel: panel,
dashboard: dashboard, dashboard: dashboard,
size: { width, height }, size: { width: this.getInnerPanelWidth(), height: this.getInnerPanelHeight() },
}; };
setPanelAngularComponent({ setPanelAngularComponent({

View File

@@ -96,7 +96,10 @@ export class QueryInspector extends PureComponent<Props, State> {
delete response.headers; delete response.headers;
} }
if (response.request) { if (response.config) {
response.request = response.config;
delete response.config;
delete response.request.transformRequest; delete response.request.transformRequest;
delete response.request.transformResponse; delete response.request.transformResponse;
delete response.request.paramSerializer; delete response.request.paramSerializer;
@@ -111,6 +114,7 @@ export class QueryInspector extends PureComponent<Props, State> {
if (response.data) { if (response.data) {
response.response = response.data; response.response = response.data;
delete response.config;
delete response.data; delete response.data;
delete response.status; delete response.status;
delete response.statusText; delete response.statusText;
@@ -120,6 +124,7 @@ export class QueryInspector extends PureComponent<Props, State> {
delete response.type; delete response.type;
delete response.$$config; delete response.$$config;
} }
this.setState(prevState => ({ this.setState(prevState => ({
...prevState, ...prevState,
dsQuery: { dsQuery: {

View File

@@ -79,7 +79,10 @@ export class DashboardSrv {
}; };
saveJSONDashboard(json: string) { saveJSONDashboard(json: string) {
return getBackendSrv().saveDashboard(JSON.parse(json), {}); const parsedJson = JSON.parse(json);
return getBackendSrv().saveDashboard(parsedJson, {
folderId: this.dashboard.meta.folderId || parsedJson.folderId,
});
} }
starDashboard(dashboardId: string, isStarred: any) { starDashboard(dashboardId: string, isStarred: any) {

View File

@@ -135,6 +135,38 @@ describe('timeSrv', () => {
expect(time.to.valueOf()).toEqual(1410337665699); expect(time.to.valueOf()).toEqual(1410337665699);
}); });
it('should handle epochs that look like formatted date without time', () => {
location = {
search: jest.fn(() => ({
from: '20149999',
to: '20159999',
})),
};
timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any);
timeSrv.init(_dashboard);
const time = timeSrv.timeRange();
expect(time.from.valueOf()).toEqual(20149999);
expect(time.to.valueOf()).toEqual(20159999);
});
it('should handle epochs that look like formatted date', () => {
location = {
search: jest.fn(() => ({
from: '201499991234567',
to: '201599991234567',
})),
};
timeSrv = new TimeSrv(rootScope as any, jest.fn() as any, location as any, timer, new ContextSrvStub() as any);
timeSrv.init(_dashboard);
const time = timeSrv.timeRange();
expect(time.from.valueOf()).toEqual(201499991234567);
expect(time.to.valueOf()).toEqual(201599991234567);
});
it('should handle bad dates', () => { it('should handle bad dates', () => {
location = { location = {
search: jest.fn(() => ({ search: jest.fn(() => ({

View File

@@ -102,10 +102,15 @@ export class TimeSrv {
return value; return value;
} }
if (value.length === 8) { if (value.length === 8) {
return toUtc(value, 'YYYYMMDD'); const utcValue = toUtc(value, 'YYYYMMDD');
} if (utcValue.isValid()) {
if (value.length === 15) { return utcValue;
return toUtc(value, 'YYYYMMDDTHHmmss'); }
} else if (value.length === 15) {
const utcValue = toUtc(value, 'YYYYMMDDTHHmmss');
if (utcValue.isValid()) {
return utcValue;
}
} }
if (!isNaN(value)) { if (!isNaN(value)) {

View File

@@ -322,7 +322,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
['explore-active-button']: showRichHistory, ['explore-active-button']: showRichHistory,
})} })}
onClick={this.toggleShowRichHistory} onClick={this.toggleShowRichHistory}
disabled={isLive}
> >
<i className={'fa fa-fw fa-history icon-margin-right '} /> <i className={'fa fa-fw fa-history icon-margin-right '} />
<span className="btn-title">{'\xA0' + 'Query history'}</span> <span className="btn-title">{'\xA0' + 'Query history'}</span>
@@ -382,7 +381,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
)} )}
</> </>
)} )}
{showRichHistory && <RichHistoryContainer width={width} exploreId={exploreId} />} {showRichHistory && (
<RichHistoryContainer
width={width}
exploreId={exploreId}
onClose={this.toggleShowRichHistory}
/>
)}
</ErrorBoundaryAlert> </ErrorBoundaryAlert>
</main> </main>
); );

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { mount } from 'enzyme';
import { GrafanaTheme } from '@grafana/data';
import { ExploreId } from '../../../types/explore';
import { RichHistory, RichHistoryProps } from './RichHistory';
import { Tabs } from './RichHistory';
import { Tab, Slider } from '@grafana/ui';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
const setup = (propOverrides?: Partial<RichHistoryProps>) => {
const props: RichHistoryProps = {
theme: {} as GrafanaTheme,
exploreId: ExploreId.left,
height: 100,
activeDatasourceInstance: 'Test datasource',
richHistory: [],
firstTab: Tabs.RichHistory,
deleteRichHistory: jest.fn(),
onClose: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = mount(<RichHistory {...props} />);
return wrapper;
};
describe('RichHistory', () => {
it('should render all tabs in tab bar', () => {
const wrapper = setup();
expect(wrapper.find(Tab)).toHaveLength(3);
});
it('should render correct lebels of tabs in tab bar', () => {
const wrapper = setup();
expect(
wrapper
.find(Tab)
.at(0)
.text()
).toEqual('Query history');
expect(
wrapper
.find(Tab)
.at(1)
.text()
).toEqual('Starred');
expect(
wrapper
.find(Tab)
.at(2)
.text()
).toEqual('Settings');
});
it('should correctly render query history tab as active tab', () => {
const wrapper = setup();
expect(wrapper.find(Slider)).toHaveLength(1);
});
it('should correctly render starred tab as active tab', () => {
const wrapper = setup({ firstTab: Tabs.Starred });
expect(wrapper.find(Slider)).toHaveLength(0);
});
});

View File

@@ -30,12 +30,14 @@ export const sortOrderOptions = [
{ label: 'Data source Z-A', value: SortOrder.DatasourceZA }, { label: 'Data source Z-A', value: SortOrder.DatasourceZA },
]; ];
interface RichHistoryProps extends Themeable { export interface RichHistoryProps extends Themeable {
richHistory: RichHistoryQuery[]; richHistory: RichHistoryQuery[];
activeDatasourceInstance: string; activeDatasourceInstance: string;
firstTab: Tabs; firstTab: Tabs;
exploreId: ExploreId; exploreId: ExploreId;
height: number;
deleteRichHistory: () => void; deleteRichHistory: () => void;
onClose: () => void;
} }
interface RichHistoryState { interface RichHistoryState {
@@ -60,6 +62,11 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
background-color: ${tabContentBg}; background-color: ${tabContentBg};
padding: ${theme.spacing.md}; padding: ${theme.spacing.md};
`, `,
close: css`
position: absolute;
right: ${theme.spacing.sm};
cursor: pointer;
`,
tabs: css` tabs: css`
background-color: ${tabBarBg}; background-color: ${tabBarBg};
padding-top: ${theme.spacing.sm}; padding-top: ${theme.spacing.sm};
@@ -76,8 +83,8 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
super(props); super(props);
this.state = { this.state = {
activeTab: this.props.firstTab, activeTab: this.props.firstTab,
datasourceFilters: null,
sortOrder: SortOrder.Descending, sortOrder: SortOrder.Descending,
datasourceFilters: store.getObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, null),
retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7), retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7),
starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false), starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false),
activeDatasourceOnly: store.getBool(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, false), activeDatasourceOnly: store.getBool(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, false),
@@ -108,6 +115,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
}; };
onSelectDatasourceFilters = (value: SelectableValue[] | null) => { onSelectDatasourceFilters = (value: SelectableValue[] | null) => {
store.setObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, value);
this.setState({ datasourceFilters: value }); this.setState({ datasourceFilters: value });
}; };
@@ -126,7 +134,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
? this.onSelectDatasourceFilters([ ? this.onSelectDatasourceFilters([
{ label: this.props.activeDatasourceInstance, value: this.props.activeDatasourceInstance }, { label: this.props.activeDatasourceInstance, value: this.props.activeDatasourceInstance },
]) ])
: this.onSelectDatasourceFilters(null); : this.onSelectDatasourceFilters(this.state.datasourceFilters);
} }
componentDidMount() { componentDidMount() {
@@ -142,15 +150,8 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
} }
render() { render() {
const { const { datasourceFilters, sortOrder, activeTab, activeDatasourceOnly, retentionPeriod } = this.state;
datasourceFilters, const { theme, richHistory, height, exploreId, deleteRichHistory, onClose } = this.props;
sortOrder,
activeTab,
starredTabAsFirstTab,
activeDatasourceOnly,
retentionPeriod,
} = this.state;
const { theme, richHistory, exploreId, deleteRichHistory } = this.props;
const styles = getStyles(theme); const styles = getStyles(theme);
const QueriesTab = { const QueriesTab = {
@@ -166,6 +167,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
onChangeSortOrder={this.onChangeSortOrder} onChangeSortOrder={this.onChangeSortOrder}
onSelectDatasourceFilters={this.onSelectDatasourceFilters} onSelectDatasourceFilters={this.onSelectDatasourceFilters}
exploreId={exploreId} exploreId={exploreId}
height={height}
/> />
), ),
icon: 'fa fa-history', icon: 'fa fa-history',
@@ -205,8 +207,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
icon: 'gicon gicon-preferences', icon: 'gicon gicon-preferences',
}; };
let tabs = starredTabAsFirstTab ? [StarredTab, QueriesTab, SettingsTab] : [QueriesTab, StarredTab, SettingsTab]; let tabs = [QueriesTab, StarredTab, SettingsTab];
return ( return (
<div className={styles.container}> <div className={styles.container}>
<TabsBar className={styles.tabs}> <TabsBar className={styles.tabs}>
@@ -219,6 +220,9 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
icon={t.icon} icon={t.icon}
/> />
))} ))}
<div className={styles.close} onClick={onClose}>
<i className="fa fa-times" title="Close query history" />
</div>
</TabsBar> </TabsBar>
<CustomScrollbar <CustomScrollbar
className={css` className={css`

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { mount } from 'enzyme';
import { RichHistoryCard, Props } from './RichHistoryCard';
import { ExploreId } from '../../../types/explore';
import { DataSourceApi } from '@grafana/data';
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
query: {
ts: 1,
datasourceName: 'Test datasource',
datasourceId: 'datasource 1',
starred: false,
comment: '',
queries: ['query1', 'query2', 'query3'],
sessionName: '',
},
dsImg: '/app/img',
isRemoved: false,
changeDatasource: jest.fn(),
updateRichHistory: jest.fn(),
setQueries: jest.fn(),
exploreId: ExploreId.left,
datasourceInstance: { name: 'Datasource' } as DataSourceApi,
};
Object.assign(props, propOverrides);
const wrapper = mount(<RichHistoryCard {...props} />);
return wrapper;
};
const starredQueryWithComment = {
ts: 1,
datasourceName: 'Test datasource',
datasourceId: 'datasource 1',
starred: true,
comment: 'test comment',
queries: ['query1', 'query2', 'query3'],
sessionName: '',
};
describe('RichHistoryCard', () => {
it('should render all queries', () => {
const wrapper = setup();
expect(wrapper.find({ 'aria-label': 'Query text' })).toHaveLength(3);
expect(
wrapper
.find({ 'aria-label': 'Query text' })
.at(0)
.text()
).toEqual('query1');
expect(
wrapper
.find({ 'aria-label': 'Query text' })
.at(1)
.text()
).toEqual('query2');
expect(
wrapper
.find({ 'aria-label': 'Query text' })
.at(2)
.text()
).toEqual('query3');
});
it('should render data source icon', () => {
const wrapper = setup();
expect(wrapper.find({ 'aria-label': 'Data source icon' })).toHaveLength(1);
});
it('should render data source name', () => {
const wrapper = setup();
expect(wrapper.find({ 'aria-label': 'Data source name' }).text()).toEqual('Test datasource');
});
it('should render "Data source does not exist anymore" if removed data source', () => {
const wrapper = setup({ isRemoved: true });
expect(wrapper.find({ 'aria-label': 'Data source name' }).text()).toEqual('Data source does not exist anymore');
});
describe('commenting', () => {
it('should render comment, if comment present', () => {
const wrapper = setup({ query: starredQueryWithComment });
expect(wrapper.find({ 'aria-label': 'Query comment' })).toHaveLength(1);
expect(wrapper.find({ 'aria-label': 'Query comment' }).text()).toEqual('test comment');
});
it('should have title "Edit comment" at comment icon, if comment present', () => {
const wrapper = setup({ query: starredQueryWithComment });
expect(wrapper.find({ title: 'Edit comment' })).toHaveLength(1);
expect(wrapper.find({ title: 'Add comment' })).toHaveLength(0);
});
it('should have title "Add comment" at comment icon, if no comment present', () => {
const wrapper = setup();
expect(wrapper.find({ title: 'Add comment' })).toHaveLength(1);
expect(wrapper.find({ title: 'Edit comment' })).toHaveLength(0);
});
});
describe('starring', () => {
it('should have title "Star query", if not starred', () => {
const wrapper = setup();
expect(wrapper.find({ title: 'Star query' })).toHaveLength(1);
});
it('should render fa-star-o icon, if not starred', () => {
const wrapper = setup();
expect(wrapper.find({ title: 'Star query' }).hasClass('fa-star-o')).toBe(true);
});
it('should have title "Unstar query", if not starred', () => {
const wrapper = setup({ query: starredQueryWithComment });
expect(wrapper.find({ title: 'Unstar query' })).toHaveLength(1);
});
it('should have fa-star icon, if not starred', () => {
const wrapper = setup({ query: starredQueryWithComment });
expect(wrapper.find({ title: 'Unstar query' }).hasClass('fa-star')).toBe(true);
});
});
});

View File

@@ -2,56 +2,93 @@ import React, { useState } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import { stylesFactory, useTheme, Forms, styleMixins } from '@grafana/ui'; import { stylesFactory, useTheme, Forms } from '@grafana/ui';
import { GrafanaTheme, AppEvents, DataSourceApi } from '@grafana/data'; import { GrafanaTheme, AppEvents, DataSourceApi } from '@grafana/data';
import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { copyStringToClipboard, createUrlFromRichHistory, createDataQuery } from 'app/core/utils/richHistory'; import { copyStringToClipboard, createUrlFromRichHistory, createDataQuery } from 'app/core/utils/richHistory';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { changeQuery, changeDatasource, clearQueries, updateRichHistory } from '../state/actions'; import { changeDatasource, updateRichHistory, setQueries } from '../state/actions';
interface Props { export interface Props {
query: RichHistoryQuery; query: RichHistoryQuery;
changeQuery: typeof changeQuery; dsImg: string;
isRemoved: boolean;
changeDatasource: typeof changeDatasource; changeDatasource: typeof changeDatasource;
clearQueries: typeof clearQueries;
updateRichHistory: typeof updateRichHistory; updateRichHistory: typeof updateRichHistory;
setQueries: typeof setQueries;
exploreId: ExploreId; exploreId: ExploreId;
datasourceInstance: DataSourceApi; datasourceInstance: DataSourceApi;
} }
const getStyles = stylesFactory((theme: GrafanaTheme, hasComment?: boolean) => { const getStyles = stylesFactory((theme: GrafanaTheme, isRemoved: boolean) => {
const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark4; /* Hard-coded value so all buttons and icons on right side of card are aligned */
const cardBottomPadding = hasComment ? theme.spacing.sm : theme.spacing.xs; const rigtColumnWidth = '240px';
const rigtColumnContentWidth = '170px';
const borderColor = theme.isLight ? theme.colors.gray5 : theme.colors.gray25;
/* If datasource was removed, card will have inactive color */
const cardColor = theme.isLight
? isRemoved
? theme.colors.gray95
: theme.colors.white
: isRemoved
? theme.colors.gray15
: theme.colors.gray05;
const cardBoxShadow = theme.isLight ? `0px 2px 2px ${borderColor}` : `0px 2px 4px black`;
return { return {
queryCard: css` queryCard: css`
${styleMixins.listItem(theme)}
display: flex; display: flex;
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${cardBottomPadding}; flex-direction: column;
border: 1px solid ${borderColor};
margin: ${theme.spacing.sm} 0; margin: ${theme.spacing.sm} 0;
box-shadow: ${cardBoxShadow};
background-color: ${cardColor};
border-radius: ${theme.border.radius.sm};
.starred { .starred {
color: ${theme.colors.orange}; color: ${theme.colors.orange};
} }
`, `,
queryCardLeft: css` cardRow: css`
padding-right: 10px; display: flex;
width: calc(100% - 150px); align-items: center;
cursor: pointer; justify-content: space-between;
padding: ${theme.spacing.sm};
border-bottom: none;
:first-of-type {
border-bottom: 1px solid ${borderColor};
padding: ${theme.spacing.xs} ${theme.spacing.sm};
}
img {
height: ${theme.typography.size.base};
max-width: ${theme.typography.size.base};
margin-right: ${theme.spacing.sm};
}
`, `,
queryCardRight: css` datasourceContainer: css`
width: 150px; display: flex;
align-items: center;
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold};
`,
queryActionButtons: css`
max-width: ${rigtColumnContentWidth};
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
font-size: ${theme.typography.size.base};
i { i {
margin: ${theme.spacing.xs}; margin: ${theme.spacing.xs};
cursor: pointer; cursor: pointer;
} }
`, `,
queryContainer: css`
font-weight: ${theme.typography.weight.semibold};
width: calc(100% - ${rigtColumnWidth});
`,
queryRow: css` queryRow: css`
border-top: 1px solid ${bgColor}; border-top: 1px solid ${borderColor};
word-break: break-all; word-break: break-all;
padding: 4px 2px; padding: 4px 2px;
:first-child { :first-child {
@@ -59,134 +96,175 @@ const getStyles = stylesFactory((theme: GrafanaTheme, hasComment?: boolean) => {
padding: 0 0 4px 0; padding: 0 0 4px 0;
} }
`, `,
buttonRow: css` updateCommentContainer: css`
> * { width: calc(100% + ${rigtColumnWidth});
margin-right: ${theme.spacing.xs}; margin-top: ${theme.spacing.sm};
}
`, `,
comment: css` comment: css`
overflow-wrap: break-word; overflow-wrap: break-word;
font-size: ${theme.typography.size.sm}; font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.regular};
margin-top: ${theme.spacing.xs}; margin-top: ${theme.spacing.xs};
`, `,
commentButtonRow: css`
> * {
margin-right: ${theme.spacing.sm};
}
`,
textArea: css`
border: 1px solid ${borderColor};
background: inherit;
color: inherit;
width: 100%;
font-size: ${theme.typography.size.sm};
&placeholder {
padding: 0 ${theme.spacing.sm};
}
`,
runButton: css`
max-width: ${rigtColumnContentWidth};
display: flex;
justify-content: flex-end;
button {
height: auto;
padding: ${theme.spacing.sm} ${theme.spacing.md};
span {
white-space: normal !important;
}
}
`,
}; };
}); });
export function RichHistoryCard(props: Props) { export function RichHistoryCard(props: Props) {
const { const {
query, query,
dsImg,
isRemoved,
updateRichHistory, updateRichHistory,
changeQuery,
changeDatasource, changeDatasource,
exploreId, exploreId,
clearQueries,
datasourceInstance, datasourceInstance,
setQueries,
} = props; } = props;
const [starred, setStared] = useState(query.starred);
const [activeUpdateComment, setActiveUpdateComment] = useState(false); const [activeUpdateComment, setActiveUpdateComment] = useState(false);
const [comment, setComment] = useState<string | undefined>(query.comment); const [comment, setComment] = useState<string | undefined>(query.comment);
const toggleActiveUpdateComment = () => setActiveUpdateComment(!activeUpdateComment); const toggleActiveUpdateComment = () => setActiveUpdateComment(!activeUpdateComment);
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme, Boolean(query.comment)); const styles = getStyles(theme, isRemoved);
const changeQueries = () => { const onRunQuery = async () => {
query.queries.forEach((q, i) => { const dataQueries = query.queries.map((q, i) => createDataQuery(query, q, i));
const dataQuery = createDataQuery(query, q, i);
changeQuery(exploreId, dataQuery, i);
});
};
const onChangeQuery = async (query: RichHistoryQuery) => {
if (query.datasourceName !== datasourceInstance?.name) { if (query.datasourceName !== datasourceInstance?.name) {
await changeDatasource(exploreId, query.datasourceName); await changeDatasource(exploreId, query.datasourceName);
changeQueries(); setQueries(exploreId, dataQueries);
} else { } else {
clearQueries(exploreId); setQueries(exploreId, dataQueries);
changeQueries();
} }
}; };
const onCopyQuery = () => {
const queries = query.queries.join('\n\n');
copyStringToClipboard(queries);
appEvents.emit(AppEvents.alertSuccess, ['Query copied to clipboard']);
};
const onCreateLink = () => {
const url = createUrlFromRichHistory(query);
copyStringToClipboard(url);
appEvents.emit(AppEvents.alertSuccess, ['Link copied to clipboard']);
};
const onDeleteQuery = () => {
updateRichHistory(query.ts, 'delete');
appEvents.emit(AppEvents.alertSuccess, ['Query deleted']);
};
const onStarrQuery = () => {
updateRichHistory(query.ts, 'starred');
};
const onUpdateComment = () => {
updateRichHistory(query.ts, 'comment', comment);
toggleActiveUpdateComment();
};
const onCancelUpdateComment = () => {
toggleActiveUpdateComment();
setComment(query.comment);
};
const updateComment = (
<div className={styles.updateCommentContainer}>
<Forms.TextArea
value={comment}
placeholder={comment ? undefined : 'An optional description of what the query does.'}
onChange={e => setComment(e.currentTarget.value)}
className={styles.textArea}
/>
<div className={styles.commentButtonRow}>
<Forms.Button onClick={onUpdateComment}>Save comment</Forms.Button>
<Forms.Button variant="secondary" onClick={onCancelUpdateComment}>
Cancel
</Forms.Button>
</div>
</div>
);
const queryActionButtons = (
<div className={styles.queryActionButtons}>
<i
className="fa fa-fw fa-comment-o"
onClick={toggleActiveUpdateComment}
title={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'}
></i>
<i className="fa fa-fw fa-copy" onClick={onCopyQuery} title="Copy query to clipboard"></i>
{!isRemoved && <i className="fa fa-fw fa-link" onClick={onCreateLink} title="Copy link to clipboard"></i>}
<i className={'fa fa-trash'} title={'Delete query'} onClick={onDeleteQuery}></i>
<i
className={cx('fa fa-fw', query.starred ? 'fa-star starred' : 'fa-star-o')}
onClick={onStarrQuery}
title={query.starred ? 'Unstar query' : 'Star query'}
></i>
</div>
);
return ( return (
<div className={styles.queryCard}> <div className={styles.queryCard}>
<div className={styles.queryCardLeft} onClick={() => onChangeQuery(query)}> <div className={styles.cardRow}>
{query.queries.map((q, i) => { <div className={styles.datasourceContainer}>
return ( <img src={dsImg} aria-label="Data source icon" />
<div key={`${q}-${i}`} className={styles.queryRow}> <div aria-label="Data source name">
{q} {isRemoved ? 'Data source does not exist anymore' : query.datasourceName}
</div> </div>
); </div>
})} {queryActionButtons}
{!activeUpdateComment && query.comment && <div className={styles.comment}>{query.comment}</div>} </div>
{activeUpdateComment && ( <div className={cx(styles.cardRow)}>
<div> <div className={styles.queryContainer}>
<Forms.TextArea {query.queries.map((q, i) => {
value={comment} return (
placeholder={comment ? undefined : 'add comment'} <div aria-label="Query text" key={`${q}-${i}`} className={styles.queryRow}>
onChange={e => setComment(e.currentTarget.value)} {q}
/> </div>
<div className={styles.buttonRow}> );
<Forms.Button })}
onClick={e => { {!activeUpdateComment && query.comment && (
e.preventDefault(); <div aria-label="Query comment" className={styles.comment}>
updateRichHistory(query.ts, 'comment', comment); {query.comment}
toggleActiveUpdateComment();
}}
>
Save
</Forms.Button>
<Forms.Button
variant="secondary"
className={css`
margin-left: 8px;
`}
onClick={() => {
toggleActiveUpdateComment();
setComment(query.comment);
}}
>
Cancel
</Forms.Button>
</div> </div>
)}
{activeUpdateComment && updateComment}
</div>
{!activeUpdateComment && (
<div className={styles.runButton}>
<Forms.Button variant="secondary" onClick={onRunQuery} disabled={isRemoved}>
{datasourceInstance?.name === query.datasourceName ? 'Run query' : 'Switch data source and run query'}
</Forms.Button>
</div> </div>
)} )}
</div> </div>
<div className={styles.queryCardRight}>
<i
className="fa fa-fw fa-comment-o"
onClick={() => {
toggleActiveUpdateComment();
}}
title={query.comment?.length > 0 ? 'Edit comment' : 'Add comment'}
></i>
<i
className="fa fa-fw fa-copy"
onClick={() => {
const queries = query.queries.join('\n\n');
copyStringToClipboard(queries);
appEvents.emit(AppEvents.alertSuccess, ['Query copied to clipboard']);
}}
title="Copy query to clipboard"
></i>
<i
className="fa fa-fw fa-link"
onClick={() => {
const url = createUrlFromRichHistory(query);
copyStringToClipboard(url);
appEvents.emit(AppEvents.alertSuccess, ['Link copied to clipboard']);
}}
style={{ fontWeight: 'normal' }}
title="Copy link to clipboard"
></i>
<i
className={cx('fa fa-fw', starred ? 'fa-star starred' : 'fa-star-o')}
onClick={() => {
updateRichHistory(query.ts, 'starred');
setStared(!starred);
}}
title={query.starred ? 'Unstar query' : 'Star query'}
></i>
</div>
</div> </div>
); );
} }
@@ -203,10 +281,9 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
} }
const mapDispatchToProps = { const mapDispatchToProps = {
changeQuery,
changeDatasource, changeDatasource,
clearQueries,
updateRichHistory, updateRichHistory,
setQueries,
}; };
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(RichHistoryCard)); export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(RichHistoryCard));

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { mount } from 'enzyme';
import { Resizable } from 're-resizable';
import { ExploreId } from '../../../types/explore';
import { RichHistoryContainer, Props } from './RichHistoryContainer';
import { Tabs } from './RichHistory';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
width: 500,
exploreId: ExploreId.left,
activeDatasourceInstance: 'Test datasource',
richHistory: [],
firstTab: Tabs.RichHistory,
deleteRichHistory: jest.fn(),
onClose: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = mount(<RichHistoryContainer {...props} />);
return wrapper;
};
describe('RichHistoryContainer', () => {
it('should render reseizable component', () => {
const wrapper = setup();
expect(wrapper.find(Resizable)).toHaveLength(1);
});
it('should render component with correct width', () => {
const wrapper = setup();
expect(wrapper.getDOMNode().getAttribute('style')).toContain('width: 531.5px');
});
it('should render component with correct height', () => {
const wrapper = setup();
expect(wrapper.getDOMNode().getAttribute('style')).toContain('height: 400px');
});
});

View File

@@ -22,33 +22,33 @@ import { RichHistory, Tabs } from './RichHistory';
import { deleteRichHistory } from '../state/actions'; import { deleteRichHistory } from '../state/actions';
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme) => {
const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.gray15; const containerBackground = theme.isLight ? theme.colors.gray7 : theme.colors.dark2;
const bg = theme.isLight ? theme.colors.gray7 : theme.colors.dark2; const containerBorderColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark6;
const borderColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark6; const handleBackground = theme.isLight ? theme.colors.gray5 : theme.colors.gray15;
const handleHover = theme.isLight ? theme.colors.gray10 : theme.colors.gray33;
const handleDots = theme.isLight ? theme.colors.gray70 : theme.colors.gray33; const handleDots = theme.isLight ? theme.colors.gray70 : theme.colors.gray33;
const handleDotsHover = theme.isLight ? theme.colors.gray33 : theme.colors.dark7; const handleBackgroundHover = theme.isLight ? theme.colors.gray70 : theme.colors.gray33;
const handleDotsHover = theme.isLight ? theme.colors.gray5 : theme.colors.dark7;
return { return {
container: css` container: css`
position: fixed !important; position: fixed !important;
bottom: 0; bottom: 0;
background: ${bg}; background: ${containerBackground};
border-top: 1px solid ${borderColor}; border-top: 1px solid ${containerBorderColor};
margin: 0px; margin: 0px;
margin-right: -${theme.spacing.md}; margin-right: -${theme.spacing.md};
margin-left: -${theme.spacing.md}; margin-left: -${theme.spacing.md};
`, `,
drawerActive: css` drawerActive: css`
opacity: 1; opacity: 1;
transition: transform 0.3s ease-in; transition: transform 0.5s ease-in;
`, `,
drawerNotActive: css` drawerNotActive: css`
opacity: 0; opacity: 0;
transform: translateY(150px); transform: translateY(400px);
`, `,
rzHandle: css` rzHandle: css`
background: ${bgColor}; background: ${handleBackground};
transition: 0.3s background ease-in-out; transition: 0.3s background ease-in-out;
position: relative; position: relative;
width: 200px !important; width: 200px !important;
@@ -57,7 +57,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
border-radius: 4px; border-radius: 4px;
&:hover { &:hover {
background-color: ${handleHover}; background-color: ${handleBackgroundHover};
&:after { &:after {
border-color: ${handleDotsHover}; border-color: ${handleDotsHover};
@@ -77,25 +77,27 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
}; };
}); });
interface Props { export interface Props {
width: number; width: number;
exploreId: ExploreId; exploreId: ExploreId;
activeDatasourceInstance: string; activeDatasourceInstance: string;
richHistory: RichHistoryQuery[]; richHistory: RichHistoryQuery[];
firstTab: Tabs; firstTab: Tabs;
deleteRichHistory: typeof deleteRichHistory; deleteRichHistory: typeof deleteRichHistory;
onClose: () => void;
} }
function RichHistoryContainer(props: Props) { export function RichHistoryContainer(props: Props) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [height, setHeight] = useState(400);
/* To create sliding animation for rich history drawer */ /* To create sliding animation for rich history drawer */
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setVisible(true), 100); const timer = setTimeout(() => setVisible(true), 10);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
const { richHistory, width, firstTab, activeDatasourceInstance, exploreId, deleteRichHistory } = props; const { richHistory, width, firstTab, activeDatasourceInstance, exploreId, deleteRichHistory, onClose } = props;
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme); const styles = getStyles(theme);
const drawerWidth = `${width + 31.5}px`; const drawerWidth = `${width + 31.5}px`;
@@ -118,6 +120,9 @@ function RichHistoryContainer(props: Props) {
maxHeight="100vh" maxHeight="100vh"
maxWidth={drawerWidth} maxWidth={drawerWidth}
minWidth={drawerWidth} minWidth={drawerWidth}
onResize={(e, dir, ref) => {
setHeight(Number(ref.style.height.slice(0, -2)));
}}
> >
<RichHistory <RichHistory
richHistory={richHistory} richHistory={richHistory}
@@ -125,6 +130,8 @@ function RichHistoryContainer(props: Props) {
activeDatasourceInstance={activeDatasourceInstance} activeDatasourceInstance={activeDatasourceInstance}
exploreId={exploreId} exploreId={exploreId}
deleteRichHistory={deleteRichHistory} deleteRichHistory={deleteRichHistory}
onClose={onClose}
height={height}
/> />
</Resizable> </Resizable>
); );

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { mount } from 'enzyme';
import { ExploreId } from '../../../types/explore';
import { SortOrder } from 'app/core/utils/explore';
import { RichHistoryQueriesTab, Props } from './RichHistoryQueriesTab';
import { Slider } from '@grafana/ui';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
queries: [],
sortOrder: SortOrder.Ascending,
activeDatasourceOnly: false,
datasourceFilters: null,
retentionPeriod: 14,
height: 100,
exploreId: ExploreId.left,
onChangeSortOrder: jest.fn(),
onSelectDatasourceFilters: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = mount(<RichHistoryQueriesTab {...props} />);
return wrapper;
};
describe('RichHistoryQueriesTab', () => {
describe('slider', () => {
it('should render slider', () => {
const wrapper = setup();
expect(wrapper.find(Slider)).toHaveLength(1);
});
it('should render slider with correct timerange', () => {
const wrapper = setup();
expect(
wrapper
.find('.label-slider')
.at(1)
.text()
).toEqual('today');
expect(
wrapper
.find('.label-slider')
.at(2)
.text()
).toEqual('two weeks ago');
});
});
describe('sort options', () => {
it('should render sorter', () => {
const wrapper = setup();
expect(wrapper.find({ 'aria-label': 'Sort queries' })).toHaveLength(1);
});
});
describe('select datasource', () => {
it('should render select datasource if activeDatasourceOnly is false', () => {
const wrapper = setup();
expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(1);
});
it('should not render select datasource if activeDatasourceOnly is true', () => {
const wrapper = setup({ activeDatasourceOnly: true });
expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(0);
});
});
});

View File

@@ -8,7 +8,6 @@ import { RichHistoryQuery, ExploreId } from 'app/types/explore';
// Utils // Utils
import { stylesFactory, useTheme } from '@grafana/ui'; import { stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { getExploreDatasources } from '../state/selectors';
import { SortOrder } from 'app/core/utils/explore'; import { SortOrder } from 'app/core/utils/explore';
import { import {
@@ -16,6 +15,7 @@ import {
mapNumbertoTimeInSlider, mapNumbertoTimeInSlider,
createRetentionPeriodBoundary, createRetentionPeriodBoundary,
mapQueriesToHeadings, mapQueriesToHeadings,
createDatasourcesList,
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
// Components // Components
@@ -23,22 +23,24 @@ import RichHistoryCard from './RichHistoryCard';
import { sortOrderOptions } from './RichHistory'; import { sortOrderOptions } from './RichHistory';
import { Select, Slider } from '@grafana/ui'; import { Select, Slider } from '@grafana/ui';
interface Props { export interface Props {
queries: RichHistoryQuery[]; queries: RichHistoryQuery[];
sortOrder: SortOrder; sortOrder: SortOrder;
activeDatasourceOnly: boolean; activeDatasourceOnly: boolean;
datasourceFilters: SelectableValue[] | null; datasourceFilters: SelectableValue[] | null;
retentionPeriod: number; retentionPeriod: number;
exploreId: ExploreId; exploreId: ExploreId;
height: number;
onChangeSortOrder: (sortOrder: SortOrder) => void; onChangeSortOrder: (sortOrder: SortOrder) => void;
onSelectDatasourceFilters: (value: SelectableValue[] | null) => void; onSelectDatasourceFilters: (value: SelectableValue[] | null) => void;
} }
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme, height: number) => {
const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark4; const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark4;
/* 134px is based on the width of the Query history tabs bar, so the content is aligned to right side of the tab */ /* 134px is based on the width of the Query history tabs bar, so the content is aligned to right side of the tab */
const cardWidth = '100% - 134px'; const cardWidth = '100% - 134px';
const sliderHeight = `${height - 200}px`;
return { return {
container: css` container: css`
display: flex; display: flex;
@@ -61,9 +63,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
margin-right: ${theme.spacing.sm}; margin-right: ${theme.spacing.sm};
.slider { .slider {
bottom: 10px; bottom: 10px;
height: 200px; height: ${sliderHeight};
width: 127px; width: 127px;
padding: ${theme.spacing.xs} 0; padding: ${theme.spacing.sm} 0;
} }
`, `,
slider: css` slider: css`
@@ -127,20 +129,15 @@ export function RichHistoryQueriesTab(props: Props) {
activeDatasourceOnly, activeDatasourceOnly,
retentionPeriod, retentionPeriod,
exploreId, exploreId,
height,
} = props; } = props;
const [sliderRetentionFilter, setSliderRetentionFilter] = useState<[number, number]>([0, retentionPeriod]); const [sliderRetentionFilter, setSliderRetentionFilter] = useState<[number, number]>([0, retentionPeriod]);
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme); const styles = getStyles(theme, height);
const listOfDsNamesWithQueries = uniqBy(queries, 'datasourceName').map(d => d.datasourceName); const datasourcesRetrievedFromQueryHistory = uniqBy(queries, 'datasourceName').map(d => d.datasourceName);
const listOfDatasources = createDatasourcesList(datasourcesRetrievedFromQueryHistory);
/* Display only explore datasoources, that have saved queries */
const datasources = getExploreDatasources()
?.filter(ds => listOfDsNamesWithQueries.includes(ds.name))
.map(d => {
return { value: d.value!, label: d.value!, imgUrl: d.meta.info.logos.small };
});
const listOfDatasourceFilters = datasourceFilters?.map(d => d.value); const listOfDatasourceFilters = datasourceFilters?.map(d => d.value);
const filteredQueriesByDatasource = datasourceFilters const filteredQueriesByDatasource = datasourceFilters
@@ -187,18 +184,19 @@ export function RichHistoryQueriesTab(props: Props) {
<div className={styles.containerContent}> <div className={styles.containerContent}>
<div className={styles.selectors}> <div className={styles.selectors}>
{!activeDatasourceOnly && ( {!activeDatasourceOnly && (
<div className={styles.multiselect}> <div aria-label="Filter datasources" className={styles.multiselect}>
<Select <Select
isMulti={true} isMulti={true}
options={datasources} options={listOfDatasources}
value={datasourceFilters} value={datasourceFilters}
placeholder="Filter queries for specific datasources(s)" placeholder="Filter queries for specific data sources(s)"
onChange={onSelectDatasourceFilters} onChange={onSelectDatasourceFilters}
/> />
</div> </div>
)} )}
<div className={styles.sort}> <div aria-label="Sort queries" className={styles.sort}>
<Select <Select
value={sortOrderOptions.filter(order => order.value === sortOrder)}
options={sortOrderOptions} options={sortOrderOptions}
placeholder="Sort queries by" placeholder="Sort queries by"
onChange={e => onChangeSortOrder(e.value as SortOrder)} onChange={e => onChangeSortOrder(e.value as SortOrder)}
@@ -211,9 +209,18 @@ export function RichHistoryQueriesTab(props: Props) {
<div className={styles.heading}> <div className={styles.heading}>
{heading} <span className={styles.queries}>{mappedQueriesToHeadings[heading].length} queries</span> {heading} <span className={styles.queries}>{mappedQueriesToHeadings[heading].length} queries</span>
</div> </div>
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => ( {mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => {
<RichHistoryCard query={q} key={q.ts} exploreId={exploreId} /> const idx = listOfDatasources.findIndex(d => d.label === q.datasourceName);
))} return (
<RichHistoryCard
query={q}
key={q.ts}
exploreId={exploreId}
dsImg={listOfDatasources[idx].imgUrl}
isRemoved={listOfDatasources[idx].isRemoved}
/>
);
})}
</div> </div>
); );
})} })}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { mount } from 'enzyme';
import { RichHistorySettings, RichHistorySettingsProps } from './RichHistorySettings';
import { Forms } from '@grafana/ui';
const setup = (propOverrides?: Partial<RichHistorySettingsProps>) => {
const props: RichHistorySettingsProps = {
retentionPeriod: 14,
starredTabAsFirstTab: true,
activeDatasourceOnly: false,
onChangeRetentionPeriod: jest.fn(),
toggleStarredTabAsFirstTab: jest.fn(),
toggleactiveDatasourceOnly: jest.fn(),
deleteRichHistory: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = mount(<RichHistorySettings {...props} />);
return wrapper;
};
describe('RichHistorySettings', () => {
it('should render component with correct retention period', () => {
const wrapper = setup();
expect(wrapper.find(Forms.Select).text()).toEqual('2 weeks');
});
it('should render component with correctly checked starredTabAsFirstTab settings', () => {
const wrapper = setup();
expect(
wrapper
.find(Forms.Switch)
.at(0)
.prop('value')
).toBe(true);
});
it('should render component with correctly not checked toggleactiveDatasourceOnly settings', () => {
const wrapper = setup();
expect(
wrapper
.find(Forms.Switch)
.at(1)
.prop('value')
).toBe(false);
});
});

View File

@@ -3,8 +3,9 @@ import { css } from 'emotion';
import { stylesFactory, useTheme, Forms } from '@grafana/ui'; import { stylesFactory, useTheme, Forms } from '@grafana/ui';
import { GrafanaTheme, AppEvents } from '@grafana/data'; import { GrafanaTheme, AppEvents } from '@grafana/data';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { CoreEvents } from 'app/types';
interface RichHistorySettingsProps { export interface RichHistorySettingsProps {
retentionPeriod: number; retentionPeriod: number;
starredTabAsFirstTab: boolean; starredTabAsFirstTab: boolean;
activeDatasourceOnly: boolean; activeDatasourceOnly: boolean;
@@ -57,6 +58,19 @@ export function RichHistorySettings(props: RichHistorySettingsProps) {
const styles = getStyles(theme); const styles = getStyles(theme);
const selectedOption = retentionPeriodOptions.find(v => v.value === retentionPeriod); const selectedOption = retentionPeriodOptions.find(v => v.value === retentionPeriod);
const onDelete = () => {
appEvents.emit(CoreEvents.showConfirmModal, {
title: 'Delete',
text: 'Are you sure you want to permanently delete your query history?',
yesText: 'Delete',
icon: 'fa-trash',
onConfirm: () => {
deleteRichHistory();
appEvents.emit(AppEvents.alertSuccess, ['Query history deleted']);
},
});
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Forms.Field <Forms.Field
@@ -78,10 +92,10 @@ export function RichHistorySettings(props: RichHistorySettingsProps) {
<div className={styles.label}>Change the default active tab from Query history to Starred</div> <div className={styles.label}>Change the default active tab from Query history to Starred</div>
</div> </div>
</Forms.Field> </Forms.Field>
<Forms.Field label="Datasource behaviour" description=" " className="space-between"> <Forms.Field label="Data source behaviour" description=" " className="space-between">
<div className={styles.switch}> <div className={styles.switch}>
<Forms.Switch value={activeDatasourceOnly} onChange={toggleactiveDatasourceOnly}></Forms.Switch> <Forms.Switch value={activeDatasourceOnly} onChange={toggleactiveDatasourceOnly}></Forms.Switch>
<div className={styles.label}>Only show queries for datasource currently active in Explore</div> <div className={styles.label}>Only show queries for data source currently active in Explore</div>
</div> </div>
</Forms.Field> </Forms.Field>
<div <div
@@ -98,13 +112,7 @@ export function RichHistorySettings(props: RichHistorySettingsProps) {
> >
Delete all of your query history, permanently. Delete all of your query history, permanently.
</div> </div>
<Forms.Button <Forms.Button variant="destructive" onClick={onDelete}>
variant="destructive"
onClick={() => {
deleteRichHistory();
appEvents.emit(AppEvents.alertSuccess, ['Query history deleted']);
}}
>
Clear query history Clear query history
</Forms.Button> </Forms.Button>
</div> </div>

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { mount } from 'enzyme';
import { ExploreId } from '../../../types/explore';
import { SortOrder } from 'app/core/utils/explore';
import { RichHistoryStarredTab, Props } from './RichHistoryStarredTab';
jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() }));
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
queries: [],
sortOrder: SortOrder.Ascending,
activeDatasourceOnly: false,
datasourceFilters: null,
exploreId: ExploreId.left,
onChangeSortOrder: jest.fn(),
onSelectDatasourceFilters: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = mount(<RichHistoryStarredTab {...props} />);
return wrapper;
};
describe('RichHistoryStarredTab', () => {
describe('sorter', () => {
it('should render sorter', () => {
const wrapper = setup();
expect(wrapper.find({ 'aria-label': 'Sort queries' })).toHaveLength(1);
});
});
describe('select datasource', () => {
it('should render select datasource if activeDatasourceOnly is false', () => {
const wrapper = setup();
expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(1);
});
it('should not render select datasource if activeDatasourceOnly is true', () => {
const wrapper = setup({ activeDatasourceOnly: true });
expect(wrapper.find({ 'aria-label': 'Filter datasources' })).toHaveLength(0);
});
});
});

View File

@@ -8,17 +8,16 @@ import { RichHistoryQuery, ExploreId } from 'app/types/explore';
// Utils // Utils
import { stylesFactory, useTheme } from '@grafana/ui'; import { stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { getExploreDatasources } from '../state/selectors';
import { SortOrder } from '../../../core/utils/explore'; import { SortOrder } from '../../../core/utils/explore';
import { sortQueries } from '../../../core/utils/richHistory'; import { sortQueries, createDatasourcesList } from '../../../core/utils/richHistory';
// Components // Components
import RichHistoryCard from './RichHistoryCard'; import RichHistoryCard from './RichHistoryCard';
import { sortOrderOptions } from './RichHistory'; import { sortOrderOptions } from './RichHistory';
import { Select } from '@grafana/ui'; import { Select } from '@grafana/ui';
interface Props { export interface Props {
queries: RichHistoryQuery[]; queries: RichHistoryQuery[];
sortOrder: SortOrder; sortOrder: SortOrder;
activeDatasourceOnly: boolean; activeDatasourceOnly: boolean;
@@ -33,17 +32,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
return { return {
container: css` container: css`
display: flex; display: flex;
.label-slider {
font-size: ${theme.typography.size.sm};
&:last-of-type {
margin-top: ${theme.spacing.lg};
}
&:first-of-type {
margin-top: ${theme.spacing.sm};
font-weight: ${theme.typography.weight.semibold};
margin-bottom: ${theme.spacing.xs};
}
}
`, `,
containerContent: css` containerContent: css`
width: 100%; width: 100%;
@@ -63,19 +51,18 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
sort: css` sort: css`
width: 170px; width: 170px;
`, `,
sessionName: css` feedback: css`
display: flex; height: 60px;
align-items: flex-start;
justify-content: flex-start;
margin-top: ${theme.spacing.lg}; margin-top: ${theme.spacing.lg};
h4 { display: flex;
margin: 0 10px 0 0; justify-content: center;
font-weight: ${theme.typography.weight.light};
font-size: ${theme.typography.size.sm};
a {
font-weight: ${theme.typography.weight.semibold};
margin-left: ${theme.spacing.xxs};
} }
`, `,
heading: css`
font-size: ${theme.typography.heading.h4};
margin: ${theme.spacing.md} ${theme.spacing.xxs} ${theme.spacing.sm} ${theme.spacing.xxs};
`,
}; };
}); });
@@ -92,18 +79,17 @@ export function RichHistoryStarredTab(props: Props) {
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme); const styles = getStyles(theme);
const listOfDsNamesWithQueries = uniqBy(queries, 'datasourceName').map(d => d.datasourceName);
const exploreDatasources = getExploreDatasources() const datasourcesRetrievedFromQueryHistory = uniqBy(queries, 'datasourceName').map(d => d.datasourceName);
?.filter(ds => listOfDsNamesWithQueries.includes(ds.name)) const listOfDatasources = createDatasourcesList(datasourcesRetrievedFromQueryHistory);
.map(d => {
return { value: d.value!, label: d.value!, imgUrl: d.meta.info.logos.small };
});
const listOfDatasourceFilters = datasourceFilters?.map(d => d.value); const listOfDatasourceFilters = datasourceFilters?.map(d => d.value);
const starredQueries = queries.filter(q => q.starred === true); const starredQueries = queries.filter(q => q.starred === true);
const starredQueriesFilteredByDatasource = datasourceFilters const starredQueriesFilteredByDatasource = datasourceFilters
? starredQueries?.filter(q => listOfDatasourceFilters?.includes(q.datasourceName)) ? starredQueries?.filter(q => listOfDatasourceFilters?.includes(q.datasourceName))
: starredQueries; : starredQueries;
const sortedStarredQueries = sortQueries(starredQueriesFilteredByDatasource, sortOrder); const sortedStarredQueries = sortQueries(starredQueriesFilteredByDatasource, sortOrder);
return ( return (
@@ -111,27 +97,41 @@ export function RichHistoryStarredTab(props: Props) {
<div className={styles.containerContent}> <div className={styles.containerContent}>
<div className={styles.selectors}> <div className={styles.selectors}>
{!activeDatasourceOnly && ( {!activeDatasourceOnly && (
<div className={styles.multiselect}> <div aria-label="Filter datasources" className={styles.multiselect}>
<Select <Select
isMulti={true} isMulti={true}
options={exploreDatasources} options={listOfDatasources}
value={datasourceFilters} value={datasourceFilters}
placeholder="Filter queries for specific datasources(s)" placeholder="Filter queries for specific data sources(s)"
onChange={onSelectDatasourceFilters} onChange={onSelectDatasourceFilters}
/> />
</div> </div>
)} )}
<div className={styles.sort}> <div aria-label="Sort queries" className={styles.sort}>
<Select <Select
options={sortOrderOptions} options={sortOrderOptions}
value={sortOrderOptions.filter(order => order.value === sortOrder)}
placeholder="Sort queries by" placeholder="Sort queries by"
onChange={e => onChangeSortOrder(e.value as SortOrder)} onChange={e => onChangeSortOrder(e.value as SortOrder)}
/> />
</div> </div>
</div> </div>
{sortedStarredQueries.map(q => { {sortedStarredQueries.map(q => {
return <RichHistoryCard query={q} key={q.ts} exploreId={exploreId} />; const idx = listOfDatasources.findIndex(d => d.label === q.datasourceName);
return (
<RichHistoryCard
query={q}
key={q.ts}
exploreId={exploreId}
dsImg={listOfDatasources[idx].imgUrl}
isRemoved={listOfDatasources[idx].isRemoved}
/>
);
})} })}
<div className={styles.feedback}>
Query history is a beta feature. The history is local to your browser and is not shared with others.
<a href="https://github.com/grafana/grafana/issues/new/choose">Feedback?</a>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -43,6 +43,7 @@ import {
deleteAllFromRichHistory, deleteAllFromRichHistory,
updateStarredInRichHistory, updateStarredInRichHistory,
updateCommentInRichHistory, updateCommentInRichHistory,
deleteQueryInRichHistory,
getQueryDisplayText, getQueryDisplayText,
getRichHistory, getRichHistory,
} from 'app/core/utils/richHistory'; } from 'app/core/utils/richHistory';
@@ -439,20 +440,21 @@ export const runQueries = (exploreId: ExploreId): ThunkResult<void> => {
stopQueryState(querySubscription); stopQueryState(querySubscription);
const datasourceId = datasourceInstance.meta.id;
const queryOptions: QueryOptions = { const queryOptions: QueryOptions = {
minInterval, minInterval,
// maxDataPoints is used in: // maxDataPoints is used in:
// Loki - used for logs streaming for buffer size, with undefined it falls back to datasource config if it supports that. // Loki - used for logs streaming for buffer size, with undefined it falls back to datasource config if it supports that.
// Elastic - limits the number of datapoints for the counts query and for logs it has hardcoded limit. // Elastic - limits the number of datapoints for the counts query and for logs it has hardcoded limit.
// Influx - used to correctly display logs in graph // Influx - used to correctly display logs in graph
maxDataPoints: mode === ExploreMode.Logs && datasourceInstance.name === 'Loki' ? undefined : containerWidth, maxDataPoints: mode === ExploreMode.Logs && datasourceId === 'loki' ? undefined : containerWidth,
liveStreaming: live, liveStreaming: live,
showingGraph, showingGraph,
showingTable, showingTable,
mode, mode,
}; };
const datasourceId = datasourceInstance.meta.id;
const datasourceName = exploreItemState.requestedDatasourceName; const datasourceName = exploreItemState.requestedDatasourceName;
const transaction = buildQueryTransaction(queries, queryOptions, range, scanning); const transaction = buildQueryTransaction(queries, queryOptions, range, scanning);
@@ -524,6 +526,9 @@ export const updateRichHistory = (ts: number, property: string, updatedProperty?
if (property === 'comment') { if (property === 'comment') {
nextRichHistory = updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty); nextRichHistory = updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty);
} }
if (property === 'delete') {
nextRichHistory = deleteQueryInRichHistory(getState().explore.richHistory, ts);
}
dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory }));
}; };
}; };

View File

@@ -36,6 +36,7 @@ import * as fileExport from 'app/core/utils/file_export';
import * as flatten from 'app/core/utils/flatten'; import * as flatten from 'app/core/utils/flatten';
import * as ticks from 'app/core/utils/ticks'; import * as ticks from 'app/core/utils/ticks';
import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
import impressionSrv from 'app/core/services/impression_srv'; import impressionSrv from 'app/core/services/impression_srv';
import builtInPlugins from './built_in_plugins'; import builtInPlugins from './built_in_plugins';
import * as d3 from 'd3'; import * as d3 from 'd3';
@@ -134,6 +135,10 @@ exposeToPlugin('app/core/utils/file_export', fileExport);
exposeToPlugin('app/core/utils/flatten', flatten); exposeToPlugin('app/core/utils/flatten', flatten);
exposeToPlugin('app/core/utils/kbn', kbn); exposeToPlugin('app/core/utils/kbn', kbn);
exposeToPlugin('app/core/utils/ticks', ticks); exposeToPlugin('app/core/utils/ticks', ticks);
exposeToPlugin('app/core/utils/promiseToDigest', {
promiseToDigest: promiseToDigest,
__esModule: true,
});
exposeToPlugin('app/core/config', config); exposeToPlugin('app/core/config', config);
exposeToPlugin('app/core/time_series', TimeSeries); exposeToPlugin('app/core/time_series', TimeSeries);

View File

@@ -3,7 +3,7 @@ import angular, { auto, ILocationService, IPromise, IQService } from 'angular';
import _ from 'lodash'; import _ from 'lodash';
// Utils & Services // Utils & Services
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import { variableTypes } from './variable'; import { VariableActions, variableTypes } from './variable';
import { Graph } from 'app/core/utils/dag'; import { Graph } from 'app/core/utils/dag';
import { TemplateSrv } from 'app/features/templating/template_srv'; import { TemplateSrv } from 'app/features/templating/template_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
@@ -330,9 +330,12 @@ export class VariableSrv {
for (const v of this.variables) { for (const v of this.variables) {
const key = `var-${v.name}`; const key = `var-${v.name}`;
if (vars.hasOwnProperty(key)) { if (vars.hasOwnProperty(key)) {
update.push(v.setValueFromUrl(vars[key])); if (this.isVariableUrlValueDifferentFromCurrent(v, vars[key])) {
update.push(v.setValueFromUrl(vars[key]));
}
} }
} }
if (update.length) { if (update.length) {
Promise.all(update).then(() => { Promise.all(update).then(() => {
this.dashboard.templateVariableValueUpdated(); this.dashboard.templateVariableValueUpdated();
@@ -341,6 +344,11 @@ export class VariableSrv {
} }
} }
isVariableUrlValueDifferentFromCurrent(variable: VariableActions, urlValue: any) {
// lodash _.isEqual handles array of value equality checks as well
return !_.isEqual(variable.getValueForUrl(), urlValue);
}
updateUrlParamsWithCurrentVariables() { updateUrlParamsWithCurrentVariables() {
// update url // update url
const params = this.$location.search(); const params = this.$location.search();

View File

@@ -24,7 +24,23 @@ export default class AppInsightsDatasource {
constructor(instanceSettings: DataSourceInstanceSettings<AzureDataSourceJsonData>, private templateSrv: TemplateSrv) { constructor(instanceSettings: DataSourceInstanceSettings<AzureDataSourceJsonData>, private templateSrv: TemplateSrv) {
this.id = instanceSettings.id; this.id = instanceSettings.id;
this.applicationId = instanceSettings.jsonData.appInsightsAppId; this.applicationId = instanceSettings.jsonData.appInsightsAppId;
this.baseUrl = `/appinsights/${this.version}/apps/${this.applicationId}`;
switch (instanceSettings.jsonData.cloudName) {
// Azure US Government
case 'govazuremonitor':
break;
// Azure Germany
case 'germanyazuremonitor':
break;
// Azue China
case 'chinaazuremonitor':
this.baseUrl = `/chinaappinsights/${this.version}/apps/${this.applicationId}`;
break;
// Azure Global
default:
this.baseUrl = `/appinsights/${this.version}/apps/${this.applicationId}`;
}
this.url = instanceSettings.url; this.url = instanceSettings.url;
} }

View File

@@ -2,7 +2,7 @@ import AzureMonitorDatasource from '../datasource';
import FakeSchemaData from './__mocks__/schema'; import FakeSchemaData from './__mocks__/schema';
import { TemplateSrv } from 'app/features/templating/template_srv'; import { TemplateSrv } from 'app/features/templating/template_srv';
import { KustoSchema } from '../types'; import { KustoSchema, AzureLogsVariable } from '../types';
import { toUtc } from '@grafana/data'; import { toUtc } from '@grafana/data';
import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__
@@ -283,53 +283,129 @@ describe('AzureLogAnalyticsDatasource', () => {
}); });
describe('When performing metricFindQuery', () => { describe('When performing metricFindQuery', () => {
const tableResponseWithOneColumn = { let queryResults: AzureLogsVariable[];
tables: [
{
name: 'PrimaryResult',
columns: [
{
name: 'Category',
type: 'string',
},
],
rows: [['Administrative'], ['Policy']],
},
],
};
const workspaceResponse = { const workspacesResponse = {
value: [ value: [
{ {
name: 'aworkspace', name: 'workspace1',
properties: { properties: {
source: 'Azure', customerId: 'eeee4fde-1aaa-4d60-9974-eeee562ffaa1',
customerId: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67', },
},
{
name: 'workspace2',
properties: {
customerId: 'eeee4fde-1aaa-4d60-9974-eeee562ffaa2',
}, },
}, },
], ],
}; };
let queryResults: any[]; describe('and is the workspaces() macro', () => {
beforeEach(async () => {
datasourceRequestMock.mockImplementation((options: { url: string }) => {
expect(options.url).toContain('xxx');
return Promise.resolve({ data: workspacesResponse, status: 200 });
});
beforeEach(async () => { queryResults = await ctx.ds.metricFindQuery('workspaces()');
datasourceRequestMock.mockImplementation((options: { url: string }) => {
if (options.url.indexOf('Microsoft.OperationalInsights/workspaces') > -1) {
return Promise.resolve({ data: workspaceResponse, status: 200 });
} else {
return Promise.resolve({ data: tableResponseWithOneColumn, status: 200 });
}
}); });
queryResults = await ctx.ds.metricFindQuery('workspace("aworkspace").AzureActivity | distinct Category'); it('should return a list of workspaces', () => {
expect(queryResults.length).toBe(2);
expect(queryResults[0].text).toBe('workspace1');
expect(queryResults[0].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa1');
expect(queryResults[1].text).toBe('workspace2');
expect(queryResults[1].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa2');
});
}); });
it('should return a list of categories in the correct format', () => { describe('and is the workspaces() macro with the subscription parameter', () => {
expect(queryResults.length).toBe(2); beforeEach(async () => {
expect(queryResults[0].text).toBe('Administrative'); datasourceRequestMock.mockImplementation((options: { url: string }) => {
expect(queryResults[0].value).toBe('Administrative'); expect(options.url).toContain('11112222-eeee-4949-9b2d-9106972f9123');
expect(queryResults[1].text).toBe('Policy'); return Promise.resolve({ data: workspacesResponse, status: 200 });
expect(queryResults[1].value).toBe('Policy'); });
queryResults = await ctx.ds.metricFindQuery('workspaces(11112222-eeee-4949-9b2d-9106972f9123)');
});
it('should return a list of workspaces', () => {
expect(queryResults.length).toBe(2);
expect(queryResults[0].text).toBe('workspace1');
expect(queryResults[0].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa1');
expect(queryResults[1].text).toBe('workspace2');
expect(queryResults[1].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa2');
});
});
describe('and is the workspaces() macro with the subscription parameter quoted', () => {
beforeEach(async () => {
datasourceRequestMock.mockImplementation((options: { url: string }) => {
expect(options.url).toContain('11112222-eeee-4949-9b2d-9106972f9123');
return Promise.resolve({ data: workspacesResponse, status: 200 });
});
queryResults = await ctx.ds.metricFindQuery('workspaces("11112222-eeee-4949-9b2d-9106972f9123")');
});
it('should return a list of workspaces', () => {
expect(queryResults.length).toBe(2);
expect(queryResults[0].text).toBe('workspace1');
expect(queryResults[0].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa1');
expect(queryResults[1].text).toBe('workspace2');
expect(queryResults[1].value).toBe('eeee4fde-1aaa-4d60-9974-eeee562ffaa2');
});
});
describe('and is a custom query', () => {
const tableResponseWithOneColumn = {
tables: [
{
name: 'PrimaryResult',
columns: [
{
name: 'Category',
type: 'string',
},
],
rows: [['Administrative'], ['Policy']],
},
],
};
const workspaceResponse = {
value: [
{
name: 'aworkspace',
properties: {
source: 'Azure',
customerId: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67',
},
},
],
};
beforeEach(async () => {
datasourceRequestMock.mockImplementation((options: { url: string }) => {
if (options.url.indexOf('Microsoft.OperationalInsights/workspaces') > -1) {
return Promise.resolve({ data: workspaceResponse, status: 200 });
} else {
return Promise.resolve({ data: tableResponseWithOneColumn, status: 200 });
}
});
queryResults = await ctx.ds.metricFindQuery('workspace("aworkspace").AzureActivity | distinct Category');
});
it('should return a list of categories in the correct format', () => {
expect(queryResults.length).toBe(2);
expect(queryResults[0].text).toBe('Administrative');
expect(queryResults[0].value).toBe('Administrative');
expect(queryResults[1].text).toBe('Policy');
expect(queryResults[1].value).toBe('Policy');
});
}); });
}); });

View File

@@ -1,7 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import LogAnalyticsQuerystringBuilder from '../log_analytics/querystring_builder'; import LogAnalyticsQuerystringBuilder from '../log_analytics/querystring_builder';
import ResponseParser from './response_parser'; import ResponseParser from './response_parser';
import { AzureMonitorQuery, AzureDataSourceJsonData } from '../types'; import { AzureMonitorQuery, AzureDataSourceJsonData, AzureLogsVariable } from '../types';
import { DataQueryRequest, DataSourceInstanceSettings } from '@grafana/data'; import { DataQueryRequest, DataSourceInstanceSettings } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime'; import { getBackendSrv } from '@grafana/runtime';
import { TemplateSrv } from 'app/features/templating/template_srv'; import { TemplateSrv } from 'app/features/templating/template_srv';
@@ -21,7 +21,20 @@ export default class AzureLogAnalyticsDatasource {
private templateSrv: TemplateSrv private templateSrv: TemplateSrv
) { ) {
this.id = instanceSettings.id; this.id = instanceSettings.id;
this.baseUrl = '/loganalyticsazure';
switch (this.instanceSettings.jsonData.cloudName) {
case 'govazuremonitor': // Azure US Government
break;
case 'germanyazuremonitor': // Azure Germany
break;
case 'chinaazuremonitor': // Azue China
this.baseUrl = '/chinaloganalyticsazure';
break;
default:
// Azure Global
this.baseUrl = '/loganalyticsazure';
}
this.url = instanceSettings.url; this.url = instanceSettings.url;
this.defaultOrFirstWorkspace = this.instanceSettings.jsonData.logAnalyticsDefaultWorkspace; this.defaultOrFirstWorkspace = this.instanceSettings.jsonData.logAnalyticsDefaultWorkspace;
@@ -43,11 +56,23 @@ export default class AzureLogAnalyticsDatasource {
this.azureMonitorUrl = `/${azureCloud}/subscriptions`; this.azureMonitorUrl = `/${azureCloud}/subscriptions`;
} else { } else {
this.subscriptionId = this.instanceSettings.jsonData.logAnalyticsSubscriptionId; this.subscriptionId = this.instanceSettings.jsonData.logAnalyticsSubscriptionId;
this.azureMonitorUrl = `/workspacesloganalytics/subscriptions`;
switch (this.instanceSettings.jsonData.cloudName) {
case 'govazuremonitor': // Azure US Government
break;
case 'germanyazuremonitor': // Azure Germany
break;
case 'chinaazuremonitor': // Azue China
this.azureMonitorUrl = `/chinaworkspacesloganalytics/subscriptions`;
break;
default:
// Azure Global
this.azureMonitorUrl = `/workspacesloganalytics/subscriptions`;
}
} }
} }
getWorkspaces(subscription: string) { getWorkspaces(subscription: string): Promise<AzureLogsVariable[]> {
const subscriptionId = this.templateSrv.replace(subscription || this.subscriptionId); const subscriptionId = this.templateSrv.replace(subscription || this.subscriptionId);
const workspaceListUrl = const workspaceListUrl =
@@ -118,6 +143,16 @@ export default class AzureLogAnalyticsDatasource {
} }
metricFindQuery(query: string) { metricFindQuery(query: string) {
const workspacesQuery = query.match(/^workspaces\(\)/i);
if (workspacesQuery) {
return this.getWorkspaces(this.subscriptionId);
}
const workspacesQueryWithSub = query.match(/^workspaces\(["']?([^\)]+?)["']?\)/i);
if (workspacesQueryWithSub) {
return this.getWorkspaces((workspacesQueryWithSub[1] || '').trim());
}
return this.getDefaultOrFirstWorkspace().then((workspace: any) => { return this.getDefaultOrFirstWorkspace().then((workspace: any) => {
const queries: any[] = this.buildQuery(query, null, workspace); const queries: any[] = this.buildQuery(query, null, workspace);

View File

@@ -160,7 +160,7 @@ describe('AzureMonitorDatasource', () => {
}; };
beforeEach(() => { beforeEach(() => {
datasourceRequestMock.mockImplementation((options: { url: string }) => Promise.resolve(response)); datasourceRequestMock.mockImplementation(() => Promise.resolve(response));
}); });
it('should return a list of subscriptions', () => { it('should return a list of subscriptions', () => {

View File

@@ -98,6 +98,15 @@
{ "name": "x-ms-app", "content": "Grafana" } { "name": "x-ms-app", "content": "Grafana" }
] ]
}, },
{
"path": "chinaappinsights",
"method": "GET",
"url": "https://api.applicationinsights.azure.cn",
"headers": [
{ "name": "X-API-Key", "content": "{{.SecureJsonData.appInsightsApiKey}}" },
{ "name": "x-ms-app", "content": "Grafana" }
]
},
{ {
"path": "workspacesloganalytics", "path": "workspacesloganalytics",
"method": "GET", "method": "GET",
@@ -113,6 +122,21 @@
}, },
"headers": [{ "name": "x-ms-app", "content": "Grafana" }] "headers": [{ "name": "x-ms-app", "content": "Grafana" }]
}, },
{
"path": "chinaworkspacesloganalytics",
"method": "GET",
"url": "https://management.chinacloudapi.cn",
"tokenAuth": {
"url": "https://login.chinacloudapi.cn/{{.JsonData.logAnalyticsTenantId}}/oauth2/token",
"params": {
"grant_type": "client_credentials",
"client_id": "{{.JsonData.logAnalyticsClientId}}",
"client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}",
"resource": "https://management.chinacloudapi.cn/"
}
},
"headers": [{ "name": "x-ms-app", "content": "Grafana" }]
},
{ {
"path": "loganalyticsazure", "path": "loganalyticsazure",
"method": "GET", "method": "GET",
@@ -131,6 +155,25 @@
{ "name": "Cache-Control", "content": "public, max-age=60" }, { "name": "Cache-Control", "content": "public, max-age=60" },
{ "name": "Accept-Encoding", "content": "gzip" } { "name": "Accept-Encoding", "content": "gzip" }
] ]
},
{
"path": "chinaloganalyticsazure",
"method": "GET",
"url": "https://api.loganalytics.azure.cn/v1/workspaces",
"tokenAuth": {
"url": "https://login.chinacloudapi.cn/{{.JsonData.logAnalyticsTenantId}}/oauth2/token",
"params": {
"grant_type": "client_credentials",
"client_id": "{{.JsonData.logAnalyticsClientId}}",
"client_secret": "{{.SecureJsonData.logAnalyticsClientSecret}}",
"resource": "https://api.loganalytics.azure.cn"
}
},
"headers": [
{ "name": "x-ms-app", "content": "Grafana" },
{ "name": "Cache-Control", "content": "public, max-age=60" },
{ "name": "Accept-Encoding", "content": "gzip" }
]
} }
], ],

View File

@@ -5,6 +5,7 @@ import _ from 'lodash';
import GraphiteQuery from './graphite_query'; import GraphiteQuery from './graphite_query';
import { QueryCtrl } from 'app/plugins/sdk'; import { QueryCtrl } from 'app/plugins/sdk';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
import { auto } from 'angular'; import { auto } from 'angular';
import { TemplateSrv } from 'app/features/templating/template_srv'; import { TemplateSrv } from 'app/features/templating/template_srv';
import { AppEvents } from '@grafana/data'; import { AppEvents } from '@grafana/data';
@@ -59,7 +60,8 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}); });
const checkOtherSegmentsIndex = this.queryModel.checkOtherSegmentsIndex || 0; const checkOtherSegmentsIndex = this.queryModel.checkOtherSegmentsIndex || 0;
this.checkOtherSegments(checkOtherSegmentsIndex, modifyLastSegment);
promiseToDigest(this.$scope)(this.checkOtherSegments(checkOtherSegmentsIndex, modifyLastSegment));
if (this.queryModel.seriesByTagUsed) { if (this.queryModel.seriesByTagUsed) {
this.fixTagSegments(); this.fixTagSegments();
@@ -73,12 +75,12 @@ export class GraphiteQueryCtrl extends QueryCtrl {
checkOtherSegments(fromIndex: number, modifyLastSegment = true) { checkOtherSegments(fromIndex: number, modifyLastSegment = true) {
if (this.queryModel.segments.length === 1 && this.queryModel.segments[0].type === 'series-ref') { if (this.queryModel.segments.length === 1 && this.queryModel.segments[0].type === 'series-ref') {
return; return Promise.resolve();
} }
if (fromIndex === 0) { if (fromIndex === 0) {
this.addSelectMetricSegment(); this.addSelectMetricSegment();
return; return Promise.resolve();
} }
const path = this.queryModel.getSegmentPathUpTo(fromIndex + 1); const path = this.queryModel.getSegmentPathUpTo(fromIndex + 1);
@@ -207,20 +209,24 @@ export class GraphiteQueryCtrl extends QueryCtrl {
const tag = removeTagPrefix(segment.value); const tag = removeTagPrefix(segment.value);
this.pause(); this.pause();
this.addSeriesByTagFunc(tag); this.addSeriesByTagFunc(tag);
return; return null;
} }
if (segment.expandable) { if (segment.expandable) {
return this.checkOtherSegments(segmentIndex + 1).then(() => { return promiseToDigest(this.$scope)(
this.setSegmentFocus(segmentIndex + 1); this.checkOtherSegments(segmentIndex + 1).then(() => {
this.targetChanged(); this.setSegmentFocus(segmentIndex + 1);
}); this.targetChanged();
})
);
} else { } else {
this.spliceSegments(segmentIndex + 1); this.spliceSegments(segmentIndex + 1);
} }
this.setSegmentFocus(segmentIndex + 1); this.setSegmentFocus(segmentIndex + 1);
this.targetChanged(); this.targetChanged();
return null;
} }
spliceSegments(index: any) { spliceSegments(index: any) {

View File

@@ -3,6 +3,12 @@ import gfunc from '../gfunc';
import { GraphiteQueryCtrl } from '../query_ctrl'; import { GraphiteQueryCtrl } from '../query_ctrl';
import { TemplateSrvStub } from 'test/specs/helpers'; import { TemplateSrvStub } from 'test/specs/helpers';
jest.mock('app/core/utils/promiseToDigest', () => ({
promiseToDigest: (scope: any) => {
return (p: Promise<any>) => p;
},
}));
describe('GraphiteQueryCtrl', () => { describe('GraphiteQueryCtrl', () => {
const ctx = { const ctx = {
datasource: { datasource: {

View File

@@ -1,7 +1,7 @@
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import groupBy from 'lodash/groupBy'; import groupBy from 'lodash/groupBy';
import { from, of, Observable, merge } from 'rxjs'; import { from, of, Observable, forkJoin } from 'rxjs';
import { tap } from 'rxjs/operators'; import { map, mergeMap, mergeAll } from 'rxjs/operators';
import { import {
LoadingState, LoadingState,
@@ -12,7 +12,6 @@ import {
DataSourceInstanceSettings, DataSourceInstanceSettings,
} from '@grafana/data'; } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { mergeMap, map } from 'rxjs/operators';
export const MIXED_DATASOURCE_NAME = '-- Mixed --'; export const MIXED_DATASOURCE_NAME = '-- Mixed --';
@@ -51,64 +50,46 @@ export class MixedDatasource extends DataSourceApi<DataQuery> {
} }
batchQueries(mixed: BatchedQueries[], request: DataQueryRequest<DataQuery>): Observable<DataQueryResponse> { batchQueries(mixed: BatchedQueries[], request: DataQueryRequest<DataQuery>): Observable<DataQueryResponse> {
const observables: Array<Observable<DataQueryResponse>> = []; const runningQueries = mixed.filter(this.isQueryable).map((query, i) =>
let runningSubRequests = 0; from(query.datasource).pipe(
mergeMap((api: DataSourceApi) => {
const dsRequest = cloneDeep(request);
dsRequest.requestId = `mixed-${i}-${dsRequest.requestId || ''}`;
dsRequest.targets = query.targets;
for (let i = 0; i < mixed.length; i++) { return from(api.query(dsRequest)).pipe(
const query = mixed[i]; map(response => {
if (!query.targets || !query.targets.length) {
continue;
}
const observable = from(query.datasource).pipe(
mergeMap((dataSourceApi: DataSourceApi) => {
const datasourceRequest = cloneDeep(request);
datasourceRequest.requestId = `mixed-${i}-${datasourceRequest.requestId || ''}`;
datasourceRequest.targets = query.targets;
runningSubRequests++;
let hasCountedAsDone = false;
return from(dataSourceApi.query(datasourceRequest)).pipe(
tap(
(response: DataQueryResponse) => {
if (
hasCountedAsDone ||
response.state === LoadingState.Streaming ||
response.state === LoadingState.Loading
) {
return;
}
runningSubRequests--;
hasCountedAsDone = true;
},
() => {
if (hasCountedAsDone) {
return;
}
hasCountedAsDone = true;
runningSubRequests--;
}
),
map((response: DataQueryResponse) => {
return { return {
...response, ...response,
data: response.data || [], data: response.data || [],
state: runningSubRequests === 0 ? LoadingState.Done : LoadingState.Loading, state: LoadingState.Loading,
key: `mixed-${i}-${response.key || ''}`, key: `mixed-${i}-${response.key || ''}`,
} as DataQueryResponse; } as DataQueryResponse;
}) })
); );
}) })
); )
);
observables.push(observable); return forkJoin(runningQueries).pipe(map(this.markAsDone), mergeAll());
}
return merge(...observables);
} }
testDatasource() { testDatasource() {
return Promise.resolve({}); return Promise.resolve({});
} }
private isQueryable(query: BatchedQueries): boolean {
return query && Array.isArray(query.targets) && query.targets.length > 0;
}
private markAsDone(responses: DataQueryResponse[]): DataQueryResponse[] {
const { length } = responses;
if (length === 0) {
return responses;
}
responses[length - 1].state = LoadingState.Done;
return responses;
}
} }

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