Compare commits

...

55 Commits

Author SHA1 Message Date
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
118 changed files with 3264 additions and 1401 deletions

View File

@@ -45,17 +45,13 @@ jobs:
description: Install the Grafana Build Pipeline tool
executor: grafana-build
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:
name: Install Grafana Build Pipeline
command: |
cd build-pipeline
go build -o ../bin/grabpl ./cmd/grabpl
curl -fLO https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v0.1.1/grabpl
chmod +x grabpl
mkdir bin
mv grabpl bin/
- persist_to_workspace:
root: .
paths:

View File

@@ -34,7 +34,7 @@ COPY emails emails
ENV NODE_ENV production
RUN ./node_modules/.bin/grunt build
FROM ubuntu:19.10
FROM ubuntu:20.04
LABEL maintainer="Grafana team <hello@grafana.com>"
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)`
### 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
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.
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.
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!

9
go.mod
View File

@@ -11,7 +11,7 @@ require (
github.com/benbjohnson/clock v0.0.0-20161215174838-7dc76406b6d3
github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668
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/denisenkom/go-mssqldb v0.0.0-20190315220205-a8ed825ac853
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect
@@ -32,12 +32,13 @@ require (
github.com/gorilla/websocket v1.4.1
github.com/gosimple/slug v1.4.2
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-plugin v1.0.1
github.com/hashicorp/go-version v1.1.0
github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec
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/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect
github.com/klauspost/compress v1.4.1 // indirect
@@ -58,7 +59,7 @@ require (
github.com/robfig/cron/v3 v3.0.0
github.com/sergi/go-diff v1.0.0 // indirect
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/ua-parser/uap-go v0.0.0-20190826212731-daf92ba38329
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/pp v2.0.1+incompatible // 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/net v0.0.0-20190923162816-aa69164e4478
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/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/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/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/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=
@@ -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/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-sdk-go v0.21.0 h1:5en5MdVFgeD9tuHDuJgwHYdIVjPs0PN0a7ZQ2bZNxNk=
github.com/grafana/grafana-plugin-sdk-go v0.21.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/grafana/grafana-plugin-sdk-go v0.30.0 h1:G2mA0Vsh629aTG8FkpnUmPsWtLQocwCFMLMANjT1wgg=
github.com/grafana/grafana-plugin-sdk-go v0.30.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/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
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/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.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.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok=
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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
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/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/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/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/go.mod h1:X6nyAIge2JGVmuJt2MFCqmHrb/5IHiphfHtot0s5cnI=
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/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 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/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
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/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=
@@ -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-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
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/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=
@@ -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-20190922100055-0a153f010e69 h1:rOhMmluY6kLMhdnrivzec6lLgaVbMHMn2ISQXJeJ5EM=
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/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/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/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.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c=
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-20190106161140-3f1c8253044a/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",
"useWorkspaces": true,
"packages": ["packages/*"],
"version": "6.7.0-pre"
"version": "6.7.4"
}

View File

@@ -3,7 +3,7 @@
"license": "Apache-2.0",
"private": true,
"name": "grafana",
"version": "6.7.0-pre",
"version": "6.7.5",
"repository": "github:grafana/grafana",
"devDependencies": {
"@babel/core": "7.8.4",

View File

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

View File

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

View File

@@ -5,157 +5,158 @@ import { TimeZone } from '../types/index';
const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's'];
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace dateMath {
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();
}
export function isMathString(text: string | DateTime | Date): boolean {
if (!text) {
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;
if (typeof text === 'string' && (text.substring(0, 3) === 'now' || text.includes('||'))) {
return true;
} else {
return false;
}
}
while (i < len) {
const c = strippedMathString.charAt(i++);
let type;
let num;
let unit;
/**
* 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 | null,
roundUp?: boolean,
timezone?: TimeZone
): DateTime | undefined {
if (!text) {
return undefined;
}
if (c === '/') {
type = 0;
} else if (c === '+') {
type = 1;
} else if (c === '-') {
type = 2;
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 {
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))) {
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;
}
}
num = parseInt(strippedMathString.substring(numFrom, i), 10);
}
if (!mathString.length) {
return time;
}
if (type === 0) {
// rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M)
if (num !== 1) {
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;
}
/**
* 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;
}
}
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;
} 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
export { dateMath } from './datemath';
export { rangeUtil } from './rangeutil';
import * as dateMath from './datemath';
import * as rangeUtil from './rangeutil';
export * from './moment_wrapper';
export * from './timezones';
export * from './formats';
export { dateMath, rangeUtil };

View File

@@ -3,183 +3,180 @@ import groupBy from 'lodash/groupBy';
import { RawTimeRange } from '../types/time';
import { dateMath } from './datemath';
import * as dateMath from './datemath';
import { isDateTime, DateTime } from './moment_wrapper';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace rangeUtil {
const spans: { [key: string]: { display: string; section?: number } } = {
s: { display: 'second' },
m: { display: 'minute' },
h: { display: 'hour' },
d: { display: 'day' },
w: { display: 'week' },
M: { display: 'month' },
y: { display: 'year' },
};
const spans: { [key: string]: { display: string; section?: number } } = {
s: { display: 'second' },
m: { display: 'minute' },
h: { display: 'hour' },
d: { display: 'day' },
w: { display: 'week' },
M: { display: 'month' },
y: { display: 'year' },
};
const rangeOptions = [
{ from: 'now/d', to: 'now/d', display: 'Today', 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', display: 'This week so far', 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/y', to: 'now/y', display: 'This year', section: 2 },
{ from: 'now/y', to: 'now', display: 'This year so far', section: 2 },
const rangeOptions = [
{ from: 'now/d', to: 'now/d', display: 'Today', 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', display: 'This week so far', 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/y', to: 'now/y', display: 'This year', 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-2d/d',
to: 'now-2d/d',
display: 'Day before yesterday',
section: 1,
},
{
from: 'now-7d/d',
to: 'now-7d/d',
display: 'This day last 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-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 },
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 1 },
{
from: 'now-2d/d',
to: 'now-2d/d',
display: 'Day before yesterday',
section: 1,
},
{
from: 'now-7d/d',
to: 'now-7d/d',
display: 'This day last 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-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 },
{ from: 'now-5m', to: 'now', display: 'Last 5 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-1h', to: 'now', display: 'Last 1 hour', 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-12h', to: 'now', display: 'Last 12 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-7d', to: 'now', display: 'Last 7 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-6M', to: 'now', display: 'Last 6 months', 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-5y', to: 'now', display: 'Last 5 years', section: 0 },
];
{ from: 'now-5m', to: 'now', display: 'Last 5 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-1h', to: 'now', display: 'Last 1 hour', 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-12h', to: 'now', display: 'Last 12 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-7d', to: 'now', display: 'Last 7 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-6M', to: 'now', display: 'Last 6 months', 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-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 = {};
each(rangeOptions, (frame: any) => {
rangeIndex[frame.from + ' to ' + frame.to] = frame;
const rangeIndex: any = {};
each(rangeOptions, (frame: any) => {
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) {
const groups = groupBy(rangeOptions, (option: any) => {
option.active = option.display === currentDisplay;
return option.section;
});
// _.each(timepickerSettings.time_options, (duration: string) => {
// let info = describeTextRange(duration);
// if (info.section) {
// groups[info.section].push(info);
// }
// });
// _.each(timepickerSettings.time_options, (duration: string) => {
// let info = describeTextRange(duration);
// if (info.section) {
// groups[info.section].push(info);
// }
// });
return groups;
}
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) {
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;
}
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;
}
let opt = rangeIndex[expr + ' to now'];
if (opt) {
return opt;
}
/**
* 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();
if (isLast) {
opt = { from: expr, to: 'now' };
} else {
opt = { from: 'now', to: expr };
}
export const isValidTimeSpan = (value: string) => {
if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) {
return true;
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;
}
const info = describeTextRange(value);
return info.invalid !== true;
};
return opt;
}
/**
* 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;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AppEvents {
export type AlertPayload = [string, string?];
export type AlertPayload = [string, string?];
export const alertSuccess = eventFactory<AlertPayload>('alert-success');
export const alertWarning = eventFactory<AlertPayload>('alert-warning');
export const alertError = eventFactory<AlertPayload>('alert-error');
}
export const alertSuccess = eventFactory<AlertPayload>('alert-success');
export const alertWarning = eventFactory<AlertPayload>('alert-warning');
export const alertError = eventFactory<AlertPayload>('alert-error');

View File

@@ -24,5 +24,9 @@ export * from './theme';
export * from './orgs';
export * from './flot';
export { AppEvent, AppEvents } from './appEvents';
export { PanelEvents } from './panelEvents';
import * as AppEvents from './appEvents';
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 { AngularPanelMenuItem } from './panel';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace PanelEvents {
/** Payloads */
export interface PanelChangeViewPayload {
fullscreen?: boolean;
edit?: 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');
/** Payloads */
export interface PanelChangeViewPayload {
fullscreen?: boolean;
edit?: 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');

View File

@@ -1,9 +1,23 @@
const { resolve } = require('path');
const wp = require('@cypress/webpack-preprocessor');
const anyNodeModules = /node_modules/;
const packageRoot = resolve(`${__dirname}/../../`);
const packageModules = `${packageRoot}/node_modules`;
const webpackOptions = {
module: {
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$/,
use: [
{

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,18 @@ import { getBackendSrv } from '../services';
// Ideally internal (exported for consistency)
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<
TQuery extends DataQuery = DataQuery,
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> {
const { targets, intervalMs, maxDataPoints, range } = request;
let expressionCount = 0;
const { targets, intervalMs, maxDataPoints, range, requestId } = request;
const orgId = config.bootData.user.orgId;
const queries = targets.map(q => {
if (q.datasource === ExpressionDatasourceID) {
expressionCount++;
return {
...q,
datasourceId: this.id,
@@ -53,7 +62,6 @@ export class DataSourceWithBackend<
});
const body: any = {
expressionCount,
queries,
};
if (range) {
@@ -63,10 +71,16 @@ export class DataSourceWithBackend<
}
const req: Promise<DataQueryResponse> = getBackendSrv()
.post('/api/ds/query', body)
.datasourceRequest({
url: '/api/ds/query',
method: 'POST',
data: body,
requestId,
})
.then((rsp: any) => {
return this.toDataQueryResponse(rsp);
return this.toDataQueryResponse(rsp?.data);
});
return from(req);
}
@@ -101,8 +115,36 @@ export class DataSourceWithBackend<
return getBackendSrv().post(`/api/datasources/${this.id}/resources/${path}`, { ...body });
}
testDatasource() {
// TODO, this will call the backend healthcheck endpoint
return Promise.resolve({});
/**
* Run the datasource healthcheck
*/
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",
"license": "Apache-2.0",
"name": "@grafana/toolkit",
"version": "6.7.0-pre",
"version": "6.7.4",
"description": "Grafana Toolkit",
"keywords": [
"grafana",
@@ -27,12 +27,12 @@
},
"main": "src/index.ts",
"dependencies": {
"@babel/core": "7.8.3",
"@babel/preset-env": "7.8.3",
"@grafana/data": "6.7.0-pre",
"@babel/core": "7.9.0",
"@babel/preset-env": "7.9.0",
"@grafana/data": "6.7.4",
"@grafana/eslint-config": "^1.0.0-rc1",
"@grafana/tsconfig": "^1.0.0-rc1",
"@grafana/ui": "6.7.0-pre",
"@grafana/ui": "6.7.4",
"@types/command-exists": "^1.2.0",
"@types/execa": "^0.9.0",
"@types/expect-puppeteer": "3.3.1",
@@ -52,7 +52,7 @@
"@typescript-eslint/parser": "2.19.2",
"axios": "0.19.0",
"babel-jest": "24.8.0",
"babel-loader": "8.0.6",
"babel-loader": "8.1.0",
"babel-plugin-angularjs-annotate": "0.10.0",
"chalk": "^2.4.2",
"command-exists": "^1.2.8",

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ export const IndicatorsContainer = React.forwardRef<HTMLDivElement, React.PropsW
styles.suffix,
css`
position: relative;
top: 1px;
`
)}
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> {
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
value?: SelectableValue<T>;
invalid?: boolean;
}
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};
padding: ${theme.spacing.formSpacingBase / 4}px ${theme.spacing.formSpacingBase}px;
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 { Switch } from './Switch';
import { TextArea } from './TextArea/TextArea';
import { Checkbox } from './Checkbox';
const Forms = {
RadioButtonGroup,
@@ -26,6 +27,7 @@ const Forms = {
InputControl,
AsyncSelect,
TextArea,
Checkbox,
};
export { ButtonVariant } from './Button';

View File

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

View File

@@ -3,4 +3,6 @@ import { getTheme, mockTheme } from './getTheme';
import { selectThemeVariant } from './selectThemeVariant';
export { stylesFactory } from './stylesFactory';
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';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace styleMixins {
export function cardChrome(theme: GrafanaTheme): string {
if (theme.isDark) {
return `
export function cardChrome(theme: GrafanaTheme): string {
if (theme.isDark) {
return `
background: linear-gradient(135deg, ${theme.colors.dark8}, ${theme.colors.dark6});
&:hover {
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);
border-radius: ${theme.border.radius.md};
`;
}
}
return `
return `
background: linear-gradient(135deg, ${theme.colors.gray6}, ${theme.colors.gray7});
&:hover {
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);
border-radius: ${theme.border.radius.md};
`;
}
}
export function listItem(theme: GrafanaTheme): string {
if (theme.isDark) {
return `
export function listItem(theme: GrafanaTheme): string {
if (theme.isDark) {
return `
background: ${theme.colors.dark7};
&:hover {
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);
border-radius: ${theme.border.radius.md};
`;
}
}
return `
return `
background: ${theme.colors.gray7};
&:hover {
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);
border-radius: ${theme.border.radius.md};
`;
}
}

View File

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

View File

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

View File

@@ -64,7 +64,7 @@ docker_build () {
else
libc=""
dockerfile="ubuntu.Dockerfile"
base_image="${base_arch}ubuntu:19.10"
base_image="${base_arch}ubuntu:20.04"
fi
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
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) {
pluginRoute.Get("/:pluginId/dashboards/", Wrap(GetPluginDashboards))
pluginRoute.Post("/:pluginId/settings", bind(models.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting))
pluginRoute.Get("/:pluginId/metrics", Wrap(hs.CollectPluginMetrics))
}, reqOrgAdmin)
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/:id/resources", hs.CallDatasourceResource)
apiRoute.Any("/datasources/:id/resources/*", hs.CallDatasourceResource)
apiRoute.Any("/datasources/:id/health", hs.CheckDatasourceHealth)
// Folders
apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {

View File

@@ -15,14 +15,14 @@ import (
"net/http"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"gopkg.in/macaron.v1"
gocache "github.com/patrickmn/go-cache"
)
@@ -73,9 +73,15 @@ type CacheServer struct {
cache *gocache.Cache
}
func (this *CacheServer) Handler(ctx *macaron.Context) {
urlPath := ctx.Req.URL.Path
hash := urlPath[strings.LastIndex(urlPath, "/")+1:]
var validMD5 = regexp.MustCompile("^[a-fA-F0-9]{32}$")
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
obj, exists := this.cache.Get(hash)

View File

@@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"sort"
"github.com/grafana/grafana/pkg/api/dtos"
@@ -323,3 +324,90 @@ func convertModelToDtos(ds *models.DataSource) dtos.DataSource {
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:""`
License models.Licensing `inject:""`
BackendPluginManager backendplugin.Manager `inject:""`
PluginManager *plugins.PluginManager `inject:""`
}
func (hs *HTTPServer) Init() error {

View File

@@ -19,10 +19,6 @@ import (
// QueryMetricsV2 returns query metrics
// POST /api/ds/query DataSource query w/ expressions
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 {
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)
}
} else {
if !setting.IsExpressionsEnabled() {
return Error(404, "Expressions feature toggle is not enabled", nil)
}
resp, err = plugins.Transform.Transform(c.Req.Context(), request)
if err != nil {
return Error(500, "Transform request error", err)

View File

@@ -1,10 +1,13 @@
package api
import (
"errors"
"net/http"
"sort"
"time"
"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/bus"
@@ -14,6 +17,41 @@ import (
"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 {
typeFilter := c.Query("type")
enabledFilter := c.Query("enabled")
@@ -205,11 +243,54 @@ func ImportDashboard(c *models.ReqContext, apiCmd dtos.ImportDashboardCommand) R
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.
// /api/plugins/:pluginId/health
func (hs *HTTPServer) CheckHealth(c *models.ReqContext) Response {
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 == backendplugin.ErrPluginNotRegistered {
return Error(404, "Plugin not found", err)
@@ -224,6 +305,8 @@ func (hs *HTTPServer) CheckHealth(c *models.ReqContext) Response {
if err == backendplugin.ErrHealthCheckFailed {
return Error(500, "Plugin health check failed", err)
}
return Error(500, "Plugin healthcheck returned an unknown error", err)
}
payload := map[string]interface{}{
@@ -239,39 +322,23 @@ func (hs *HTTPServer) CheckHealth(c *models.ReqContext) Response {
return JSON(200, payload)
}
// CallResource passes a resource call from a plugin to the backend plugin.
//
// /api/plugins/:pluginId/resources/*
func (hs *HTTPServer) CallResource(c *models.ReqContext) {
pluginID := c.Params("pluginId")
plugin, exists := plugins.Plugins[pluginID]
if !exists {
c.JsonApiErr(404, "Plugin not found, no installed plugin with that id", nil)
config, err := hs.getPluginConfig(pluginID, c.SignedInUser)
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
}
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("*"))
}

View File

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

View File

@@ -1,8 +1,9 @@
package authproxy
import (
"encoding/base32"
"encoding/hex"
"fmt"
"hash/fnv"
"net"
"net/mail"
"reflect"
@@ -146,6 +147,13 @@ func (auth *AuthProxy) IsAllowedIP() (bool, *Error) {
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.
// 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.
@@ -156,7 +164,7 @@ func (auth *AuthProxy) getKey() string {
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)
}

View File

@@ -1,7 +1,6 @@
package authproxy
import (
"encoding/base32"
"errors"
"fmt"
"net/http"
@@ -79,7 +78,7 @@ func TestMiddlewareContext(t *testing.T) {
Convey("with a simple cache key", func() {
// 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)
So(err, ShouldBeNil)
@@ -88,7 +87,7 @@ func TestMiddlewareContext(t *testing.T) {
id, err := auth.Login()
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)
})
@@ -97,7 +96,7 @@ func TestMiddlewareContext(t *testing.T) {
group := "grafana-core-team"
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)
So(err, ShouldBeNil)
@@ -105,7 +104,7 @@ func TestMiddlewareContext(t *testing.T) {
id, err := auth.Login()
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)
})

View File

@@ -2,7 +2,6 @@ package middleware
import (
"context"
"encoding/base32"
"errors"
"fmt"
"net/http"
@@ -364,7 +363,7 @@ func TestMiddlewareContext(t *testing.T) {
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)
So(err, ShouldBeNil)
sc.fakeReq("GET", "/")

View File

@@ -1,26 +1,20 @@
package backendplugin
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"time"
"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/status"
datasourceV1 "github.com/grafana/grafana-plugin-model/go/datasource"
rendererV1 "github.com/grafana/grafana-plugin-model/go/renderer"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins/backendplugin/collector"
"github.com/grafana/grafana/pkg/util/errutil"
plugin "github.com/hashicorp/go-plugin"
dto "github.com/prometheus/client_model/go"
)
// BackendPlugin a registered backend plugin.
@@ -140,59 +134,70 @@ func (p *BackendPlugin) supportsDiagnostics() bool {
}
// CollectMetrics implements the collector.Collector interface.
func (p *BackendPlugin) CollectMetrics(ctx context.Context, ch chan<- prometheus.Metric) error {
if p.diagnostics == nil {
return nil
}
if p.client == nil || p.client.Exited() {
return nil
func (p *BackendPlugin) CollectMetrics(ctx context.Context) (*pluginv2.CollectMetricsResponse, error) {
if p.diagnostics == nil || p.client == nil || p.client.Exited() {
return &pluginv2.CollectMetricsResponse{
Metrics: &pluginv2.CollectMetricsResponse_Payload{},
}, nil
}
res, err := p.diagnostics.CollectMetrics(ctx, &pluginv2.CollectMetricsRequest{})
if err != nil {
if st, ok := status.FromError(err); ok {
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 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
return res, 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() {
return &pluginv2.CheckHealthResponse{
Status: pluginv2.CheckHealthResponse_UNKNOWN,
}, 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 st, ok := status.FromError(err); ok {
if st.Code() == codes.Unimplemented {
@@ -288,112 +293,3 @@ func (p *BackendPlugin) callResource(ctx context.Context, req CallResourceReques
stream: protoStream,
}, 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 {
plugin.DiagnosticsServer
plugin.DiagnosticsClient
}
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 {
Status HealthStatus
Message string
JSONDetails string
JSONDetails []byte
}
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 {
ID int64
Name string

View File

@@ -10,10 +10,7 @@ import (
"github.com/grafana/grafana/pkg/models"
"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/plugins/backendplugin/collector"
"github.com/grafana/grafana/pkg/registry"
plugin "github.com/hashicorp/go-plugin"
"golang.org/x/xerrors"
@@ -42,24 +39,23 @@ type Manager interface {
Register(descriptor PluginDescriptor) error
// StartPlugin starts a non-managed backend plugin
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(ctx context.Context, pluginID string) (*CheckHealthResult, error)
CheckHealth(ctx context.Context, pluginConfig *PluginConfig) (*CheckHealthResult, error)
// CallResource calls a plugin resource.
CallResource(pluginConfig PluginConfig, ctx *models.ReqContext, path string)
}
type manager struct {
pluginsMu sync.RWMutex
plugins map[string]*BackendPlugin
pluginCollector collector.PluginCollector
logger log.Logger
pluginsMu sync.RWMutex
plugins map[string]*BackendPlugin
logger log.Logger
}
func (m *manager) Init() error {
m.plugins = make(map[string]*BackendPlugin)
m.logger = log.New("plugins.backend")
m.pluginCollector = collector.NewPluginCollector()
prometheus.MustRegister(m.pluginCollector)
return nil
}
@@ -111,11 +107,6 @@ func (m *manager) start(ctx context.Context) {
p.logger.Error("Failed to start plugin", "error", err)
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.
func (m *manager) CheckHealth(ctx context.Context, pluginID string) (*CheckHealthResult, error) {
// CollectMetrics collects metrics from a registered backend plugin.
func (m *manager) CollectMetrics(ctx context.Context, pluginID string) (*CollectMetricsResult, error) {
m.pluginsMu.RLock()
p, registered := m.plugins[pluginID]
m.pluginsMu.RUnlock()
@@ -164,7 +155,29 @@ func (m *manager) CheckHealth(ctx context.Context, pluginID string) (*CheckHealt
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 {
p.logger.Error("Failed to check plugin health", "error", err)
return nil, ErrHealthCheckFailed

View File

@@ -188,6 +188,15 @@ func (pm *PluginManager) scan(pluginDir string) error {
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 {
// 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

View File

@@ -126,13 +126,15 @@ func (gcn *GoogleChatNotifier) Notify(evalContext *alerting.EvalContext) error {
gcn.log.Error("evalContext returned an invalid rule URL")
}
// add a text paragraph widget for the message
widgets := []widget{
textParagraphWidget{
widgets := []widget{}
if len(evalContext.Rule.Message) > 0 {
// 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: evalContext.Rule.Message,
},
},
})
}
// 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{
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 {

View File

@@ -12,6 +12,8 @@ export interface Props {
currentDashboard?: SelectableValue<number>;
size?: FormInputSize;
isClearable?: boolean;
invalid?: boolean;
disabled?: boolean;
}
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, {
leading: true,
trailing: true,
@@ -43,6 +52,8 @@ export const DashboardPicker: FC<Props> = ({ onSelected, currentDashboard, size
placeholder="Select dashboard"
noOptionsMessage="No dashboards found"
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 };
if (initialFolderId !== undefined && initialFolderId > -1) {
if (initialFolderId !== undefined && initialFolderId !== null && initialFolderId > -1) {
folder = options.find(option => option.value === initialFolderId) || { value: -1 };
} else if (enableReset && initialTitle) {
folder = resetFolder;

View File

@@ -1,6 +1,7 @@
import _ from 'lodash';
import coreModule from '../../core_module';
import { ISCEService } from 'angular';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
function typeaheadMatcher(this: any, item: string) {
let str = this.query;
@@ -101,8 +102,7 @@ export class FormDropdownCtrl {
}
getOptionsInternal(query: string) {
const result = this.getOptions({ $query: query });
return Promise.resolve(result);
return promiseToDigest(this.$scope)(Promise.resolve(this.getOptions({ $query: query })));
}
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 { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators';
import { fromFetch } from 'rxjs/fetch';
@@ -14,6 +13,7 @@ import { ContextSrv, contextSrv } from './context_srv';
import { coreModule } from 'app/core/core_module';
import { Emitter } from '../utils/emitter';
import { DataSourceResponse } from '../../types/events';
import { parseInitFromOptions, parseUrlFromOptions } from '../utils/fetch';
export interface DatasourceRequestOptions {
retry?: number;
@@ -54,18 +54,6 @@ enum CancellationType {
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 {
fromFetch: (input: string | Request, init?: RequestInit) => Observable<Response>;
appEvents: Emitter;
@@ -459,7 +447,7 @@ export class BackendSrv implements BackendService {
url,
type,
redirected,
request: { url, ...init },
config: options,
};
return fetchResponse;
}),
@@ -567,7 +555,7 @@ export class BackendSrv implements BackendService {
data: [],
status: this.HTTP_REQUEST_CANCELED,
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
export const backendSrv = new 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 { ContextSrv, User } from '../services/context_srv';
import { Observable, of } from 'rxjs';
import { AppEvents } from '@grafana/data';
import { CoreEvents } from '../../types';
import { delay } from 'rxjs/operators';
const getTestContext = (overides?: object) => {
const defaults = {
@@ -17,7 +19,6 @@ const getTestContext = (overides?: object) => {
redirected: false,
type: 'basic',
url: 'http://localhost:3000/api/some-mock',
headers: { 'Content-Type': 'application/json' },
};
const props = { ...defaults, ...overides };
const textMock = jest.fn().mockResolvedValue(JSON.stringify(props.data));
@@ -30,7 +31,6 @@ const getTestContext = (overides?: object) => {
redirected: false,
type: 'basic',
url: 'http://localhost:3000/api/some-mock',
headers: { 'Content-Type': 'application/json' },
};
return of(mockedResponse);
});
@@ -174,7 +174,9 @@ describe('backendSrv', () => {
statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(slowData)),
headers: {
'Content-Type': 'application/json',
map: {
'content-type': 'application/json',
},
},
redirected: false,
type: 'basic',
@@ -189,7 +191,9 @@ describe('backendSrv', () => {
statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(fastData)),
headers: {
'Content-Type': 'application/json',
map: {
'content-type': 'application/json',
},
},
redirected: false,
type: 'basic',
@@ -341,27 +345,17 @@ describe('backendSrv', () => {
it('then it should not emit message', async () => {
const url = 'http://localhost:3000/api/some-mock';
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({
data: { test: 'hello world' },
headers: {
'Content-Type': 'application/json',
},
ok: true,
redirected: false,
status: 200,
statusText: 'Ok',
type: 'basic',
url,
request: {
url,
method: 'GET',
body: undefined,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
},
},
config: options,
});
expect(appEventsMock.emit).not.toHaveBeenCalled();
expectDataSourceRequestCallChain({ url, method: 'GET', silent: true });
@@ -372,27 +366,17 @@ describe('backendSrv', () => {
it('then it should not emit message', async () => {
const url = 'http://localhost:3000/api/some-mock';
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 = {
data: { test: 'hello world' },
headers: {
'Content-Type': 'application/json',
},
ok: true,
redirected: false,
status: 200,
statusText: 'Ok',
type: 'basic',
url,
request: {
url,
method: 'GET',
body: undefined as any,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
},
},
config: options,
};
expect(result).toEqual(expectedResult);
@@ -414,9 +398,6 @@ describe('backendSrv', () => {
status: 200,
statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(slowData)),
headers: {
'Content-Type': 'application/json',
},
redirected: false,
type: 'basic',
url,
@@ -429,9 +410,6 @@ describe('backendSrv', () => {
status: 200,
statusText: 'Ok',
text: () => Promise.resolve(JSON.stringify(fastData)),
headers: {
'Content-Type': 'application/json',
},
redirected: false,
type: 'basic',
url,
@@ -447,24 +425,13 @@ describe('backendSrv', () => {
const fastResponse = await backendSrv.datasourceRequest(options);
expect(fastResponse).toEqual({
data: { message: 'Fast Request' },
headers: {
'Content-Type': 'application/json',
},
ok: true,
redirected: false,
status: 200,
statusText: 'Ok',
type: 'basic',
url: '/api/dashboard/',
request: {
url: '/api/dashboard/',
method: 'GET',
body: undefined,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
},
},
config: options,
});
const result = await slowRequest;
@@ -472,15 +439,7 @@ describe('backendSrv', () => {
data: [],
status: -1,
statusText: 'Request was aborted',
request: {
url: '/api/dashboard/',
method: 'GET',
body: undefined,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
},
},
config: options,
});
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 store from 'app/core/store';
import { serializeStateToUrlParam, SortOrder } from './explore';
import { getExploreDatasources } from '../../features/explore/state/selectors';
// Types
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';
export const RICH_HISTORY_SETTING_KEYS = {
retentionPeriod: `${RICH_HISTORY_KEY}.retentionPeriod`,
starredTabAsFirstTab: `${RICH_HISTORY_KEY}.starredTabAsFirstTab`,
activeDatasourceOnly: `${RICH_HISTORY_KEY}.activeDatasourceOnly`,
retentionPeriod: 'grafana.explore.richHistory.retentionPeriod',
starredTabAsFirstTab: 'grafana.explore.richHistory.starredTabAsFirstTab',
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 */
store.setObject(RICH_HISTORY_KEY, newHistory);
return newHistory;
const isSaved = store.setObject(RICH_HISTORY_KEY, newHistory);
/* If newHistory is succesfully saved, return it. Otherwise return not updated richHistory. */
if (isSaved) {
return newHistory;
} else {
return richHistory;
}
}
return richHistory;
@@ -107,6 +115,12 @@ export function updateCommentInRichHistory(
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) => {
let sortFunc;
@@ -251,3 +265,31 @@ export function mapQueriesToHeadings(query: RichHistoryQuery[], sortOrder: SortO
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() {
const { user } = this.props;
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);
return (

View File

@@ -72,7 +72,7 @@ export function annotationTooltipDirective(
tooltip += '<div class="graph-annotation__body">';
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;

View File

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

View File

@@ -75,7 +75,12 @@ export const SaveDashboardAsForm: React.FC<SaveDashboardFormProps & { isNew?: bo
{({ register, control, errors }) => (
<>
<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 label="Folder">
<Forms.InputControl

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { Forms, Button, HorizontalGroup } from '@grafana/ui';
import { e2e } from '@grafana/e2e';
import { SaveDashboardFormProps } from '../types';
@@ -30,27 +31,32 @@ export const SaveDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard,
>
{({ register, errors }) => (
<>
<Forms.Field label="Changes description">
<Forms.TextArea name="message" ref={register} placeholder="Add a note to describe your changes..." />
</Forms.Field>
{hasTimeChanged && (
<Forms.Field label="Save current time range" description="Dashboard time range has changed">
<Forms.Switch
<div className="gf-form-group">
{hasTimeChanged && (
<Forms.Checkbox
label="Save current time range as dashboard default"
name="saveTimerange"
ref={register}
aria-label={e2e.pages.SaveDashboardModal.selectors.saveTimerange}
/>
</Forms.Field>
)}
{hasVariableChanged && (
<Forms.Field label="Save current variables" description="Dashboard variables have changed">
<Forms.Switch
)}
{hasVariableChanged && (
<Forms.Checkbox
label="Save current variable values as dashboard default"
name="saveVariables"
ref={register}
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>
<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) {
const { dashboard, panel, isFullscreen, isInView, isInEditMode } = this.props;
const { dashboard, panel, isFullscreen, isEditing, isInView, isInEditMode } = this.props;
const autoSizerStyle = { height: isEditing ? '100%' : '' };
return (
<AutoSizer>
<AutoSizer style={autoSizerStyle}>
{({ width, height }) => {
if (width === 0) {
return null;

View File

@@ -11,11 +11,13 @@ import { PanelHeader } from './PanelHeader/PanelHeader';
import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
import { getAngularLoader, AngularComponent } from '@grafana/runtime';
import { setPanelAngularComponent } from '../state/reducers';
import config from 'app/core/config';
// Types
import { DashboardModel, PanelModel } from '../state';
import { StoreState } from 'app/types';
import { LoadingState, DefaultTimeRange, PanelData, PanelPlugin, PanelEvents } from '@grafana/data';
import { PANEL_BORDER } from 'app/core/constants';
interface OwnProps {
panel: PanelModel;
@@ -135,15 +137,32 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
if (prevProps.width !== width || prevProps.height !== height) {
if (this.scopeProps) {
this.scopeProps.size.height = height;
this.scopeProps.size.width = width;
this.scopeProps.size.height = this.getInnerPanelHeight();
this.scopeProps.size.width = this.getInnerPanelWidth();
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() {
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 (!this.element) {
@@ -156,7 +175,7 @@ export class PanelChromeAngularUnconnected extends PureComponent<Props, State> {
this.scopeProps = {
panel: panel,
dashboard: dashboard,
size: { width, height },
size: { width: this.getInnerPanelWidth(), height: this.getInnerPanelHeight() },
};
setPanelAngularComponent({

View File

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

View File

@@ -79,7 +79,10 @@ export class DashboardSrv {
};
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) {

View File

@@ -135,6 +135,38 @@ describe('timeSrv', () => {
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', () => {
location = {
search: jest.fn(() => ({

View File

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

View File

@@ -322,7 +322,6 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
['explore-active-button']: showRichHistory,
})}
onClick={this.toggleShowRichHistory}
disabled={isLive}
>
<i className={'fa fa-fw fa-history icon-margin-right '} />
<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>
</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 },
];
interface RichHistoryProps extends Themeable {
export interface RichHistoryProps extends Themeable {
richHistory: RichHistoryQuery[];
activeDatasourceInstance: string;
firstTab: Tabs;
exploreId: ExploreId;
height: number;
deleteRichHistory: () => void;
onClose: () => void;
}
interface RichHistoryState {
@@ -60,6 +62,11 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
background-color: ${tabContentBg};
padding: ${theme.spacing.md};
`,
close: css`
position: absolute;
right: ${theme.spacing.sm};
cursor: pointer;
`,
tabs: css`
background-color: ${tabBarBg};
padding-top: ${theme.spacing.sm};
@@ -76,8 +83,8 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
super(props);
this.state = {
activeTab: this.props.firstTab,
datasourceFilters: null,
sortOrder: SortOrder.Descending,
datasourceFilters: store.getObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, null),
retentionPeriod: store.getObject(RICH_HISTORY_SETTING_KEYS.retentionPeriod, 7),
starredTabAsFirstTab: store.getBool(RICH_HISTORY_SETTING_KEYS.starredTabAsFirstTab, false),
activeDatasourceOnly: store.getBool(RICH_HISTORY_SETTING_KEYS.activeDatasourceOnly, false),
@@ -108,6 +115,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
};
onSelectDatasourceFilters = (value: SelectableValue[] | null) => {
store.setObject(RICH_HISTORY_SETTING_KEYS.datasourceFilters, value);
this.setState({ datasourceFilters: value });
};
@@ -126,7 +134,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
? this.onSelectDatasourceFilters([
{ label: this.props.activeDatasourceInstance, value: this.props.activeDatasourceInstance },
])
: this.onSelectDatasourceFilters(null);
: this.onSelectDatasourceFilters(this.state.datasourceFilters);
}
componentDidMount() {
@@ -142,15 +150,8 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
}
render() {
const {
datasourceFilters,
sortOrder,
activeTab,
starredTabAsFirstTab,
activeDatasourceOnly,
retentionPeriod,
} = this.state;
const { theme, richHistory, exploreId, deleteRichHistory } = this.props;
const { datasourceFilters, sortOrder, activeTab, activeDatasourceOnly, retentionPeriod } = this.state;
const { theme, richHistory, height, exploreId, deleteRichHistory, onClose } = this.props;
const styles = getStyles(theme);
const QueriesTab = {
@@ -166,6 +167,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
onChangeSortOrder={this.onChangeSortOrder}
onSelectDatasourceFilters={this.onSelectDatasourceFilters}
exploreId={exploreId}
height={height}
/>
),
icon: 'fa fa-history',
@@ -205,8 +207,7 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
icon: 'gicon gicon-preferences',
};
let tabs = starredTabAsFirstTab ? [StarredTab, QueriesTab, SettingsTab] : [QueriesTab, StarredTab, SettingsTab];
let tabs = [QueriesTab, StarredTab, SettingsTab];
return (
<div className={styles.container}>
<TabsBar className={styles.tabs}>
@@ -219,6 +220,9 @@ class UnThemedRichHistory extends PureComponent<RichHistoryProps, RichHistorySta
icon={t.icon}
/>
))}
<div className={styles.close} onClick={onClose}>
<i className="fa fa-times" title="Close query history" />
</div>
</TabsBar>
<CustomScrollbar
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 { hot } from 'react-hot-loader';
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 { RichHistoryQuery, ExploreId } from 'app/types/explore';
import { copyStringToClipboard, createUrlFromRichHistory, createDataQuery } from 'app/core/utils/richHistory';
import appEvents from 'app/core/app_events';
import { StoreState } from 'app/types';
import { changeQuery, changeDatasource, clearQueries, updateRichHistory } from '../state/actions';
interface Props {
import { changeDatasource, updateRichHistory, setQueries } from '../state/actions';
export interface Props {
query: RichHistoryQuery;
changeQuery: typeof changeQuery;
dsImg: string;
isRemoved: boolean;
changeDatasource: typeof changeDatasource;
clearQueries: typeof clearQueries;
updateRichHistory: typeof updateRichHistory;
setQueries: typeof setQueries;
exploreId: ExploreId;
datasourceInstance: DataSourceApi;
}
const getStyles = stylesFactory((theme: GrafanaTheme, hasComment?: boolean) => {
const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark4;
const cardBottomPadding = hasComment ? theme.spacing.sm : theme.spacing.xs;
const getStyles = stylesFactory((theme: GrafanaTheme, isRemoved: boolean) => {
/* Hard-coded value so all buttons and icons on right side of card are aligned */
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 {
queryCard: css`
${styleMixins.listItem(theme)}
display: flex;
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${cardBottomPadding};
flex-direction: column;
border: 1px solid ${borderColor};
margin: ${theme.spacing.sm} 0;
box-shadow: ${cardBoxShadow};
background-color: ${cardColor};
border-radius: ${theme.border.radius.sm};
.starred {
color: ${theme.colors.orange};
}
`,
queryCardLeft: css`
padding-right: 10px;
width: calc(100% - 150px);
cursor: pointer;
cardRow: css`
display: flex;
align-items: center;
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`
width: 150px;
datasourceContainer: css`
display: flex;
align-items: center;
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold};
`,
queryActionButtons: css`
max-width: ${rigtColumnContentWidth};
display: flex;
justify-content: flex-end;
font-size: ${theme.typography.size.base};
i {
margin: ${theme.spacing.xs};
cursor: pointer;
}
`,
queryContainer: css`
font-weight: ${theme.typography.weight.semibold};
width: calc(100% - ${rigtColumnWidth});
`,
queryRow: css`
border-top: 1px solid ${bgColor};
border-top: 1px solid ${borderColor};
word-break: break-all;
padding: 4px 2px;
:first-child {
@@ -59,134 +96,175 @@ const getStyles = stylesFactory((theme: GrafanaTheme, hasComment?: boolean) => {
padding: 0 0 4px 0;
}
`,
buttonRow: css`
> * {
margin-right: ${theme.spacing.xs};
}
updateCommentContainer: css`
width: calc(100% + ${rigtColumnWidth});
margin-top: ${theme.spacing.sm};
`,
comment: css`
overflow-wrap: break-word;
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.regular};
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) {
const {
query,
dsImg,
isRemoved,
updateRichHistory,
changeQuery,
changeDatasource,
exploreId,
clearQueries,
datasourceInstance,
setQueries,
} = props;
const [starred, setStared] = useState(query.starred);
const [activeUpdateComment, setActiveUpdateComment] = useState(false);
const [comment, setComment] = useState<string | undefined>(query.comment);
const toggleActiveUpdateComment = () => setActiveUpdateComment(!activeUpdateComment);
const theme = useTheme();
const styles = getStyles(theme, Boolean(query.comment));
const styles = getStyles(theme, isRemoved);
const changeQueries = () => {
query.queries.forEach((q, i) => {
const dataQuery = createDataQuery(query, q, i);
changeQuery(exploreId, dataQuery, i);
});
};
const onChangeQuery = async (query: RichHistoryQuery) => {
const onRunQuery = async () => {
const dataQueries = query.queries.map((q, i) => createDataQuery(query, q, i));
if (query.datasourceName !== datasourceInstance?.name) {
await changeDatasource(exploreId, query.datasourceName);
changeQueries();
setQueries(exploreId, dataQueries);
} else {
clearQueries(exploreId);
changeQueries();
setQueries(exploreId, dataQueries);
}
};
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 (
<div className={styles.queryCard}>
<div className={styles.queryCardLeft} onClick={() => onChangeQuery(query)}>
{query.queries.map((q, i) => {
return (
<div key={`${q}-${i}`} className={styles.queryRow}>
{q}
</div>
);
})}
{!activeUpdateComment && query.comment && <div className={styles.comment}>{query.comment}</div>}
{activeUpdateComment && (
<div>
<Forms.TextArea
value={comment}
placeholder={comment ? undefined : 'add comment'}
onChange={e => setComment(e.currentTarget.value)}
/>
<div className={styles.buttonRow}>
<Forms.Button
onClick={e => {
e.preventDefault();
updateRichHistory(query.ts, 'comment', comment);
toggleActiveUpdateComment();
}}
>
Save
</Forms.Button>
<Forms.Button
variant="secondary"
className={css`
margin-left: 8px;
`}
onClick={() => {
toggleActiveUpdateComment();
setComment(query.comment);
}}
>
Cancel
</Forms.Button>
<div className={styles.cardRow}>
<div className={styles.datasourceContainer}>
<img src={dsImg} aria-label="Data source icon" />
<div aria-label="Data source name">
{isRemoved ? 'Data source does not exist anymore' : query.datasourceName}
</div>
</div>
{queryActionButtons}
</div>
<div className={cx(styles.cardRow)}>
<div className={styles.queryContainer}>
{query.queries.map((q, i) => {
return (
<div aria-label="Query text" key={`${q}-${i}`} className={styles.queryRow}>
{q}
</div>
);
})}
{!activeUpdateComment && query.comment && (
<div aria-label="Query comment" className={styles.comment}>
{query.comment}
</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 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>
);
}
@@ -203,10 +281,9 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreI
}
const mapDispatchToProps = {
changeQuery,
changeDatasource,
clearQueries,
updateRichHistory,
setQueries,
};
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';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const bgColor = theme.isLight ? theme.colors.gray5 : theme.colors.gray15;
const bg = theme.isLight ? theme.colors.gray7 : theme.colors.dark2;
const borderColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark6;
const handleHover = theme.isLight ? theme.colors.gray10 : theme.colors.gray33;
const containerBackground = theme.isLight ? theme.colors.gray7 : theme.colors.dark2;
const containerBorderColor = theme.isLight ? theme.colors.gray5 : theme.colors.dark6;
const handleBackground = theme.isLight ? theme.colors.gray5 : theme.colors.gray15;
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 {
container: css`
position: fixed !important;
bottom: 0;
background: ${bg};
border-top: 1px solid ${borderColor};
background: ${containerBackground};
border-top: 1px solid ${containerBorderColor};
margin: 0px;
margin-right: -${theme.spacing.md};
margin-left: -${theme.spacing.md};
`,
drawerActive: css`
opacity: 1;
transition: transform 0.3s ease-in;
transition: transform 0.5s ease-in;
`,
drawerNotActive: css`
opacity: 0;
transform: translateY(150px);
transform: translateY(400px);
`,
rzHandle: css`
background: ${bgColor};
background: ${handleBackground};
transition: 0.3s background ease-in-out;
position: relative;
width: 200px !important;
@@ -57,7 +57,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
border-radius: 4px;
&:hover {
background-color: ${handleHover};
background-color: ${handleBackgroundHover};
&:after {
border-color: ${handleDotsHover};
@@ -77,25 +77,27 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
};
});
interface Props {
export interface Props {
width: number;
exploreId: ExploreId;
activeDatasourceInstance: string;
richHistory: RichHistoryQuery[];
firstTab: Tabs;
deleteRichHistory: typeof deleteRichHistory;
onClose: () => void;
}
function RichHistoryContainer(props: Props) {
export function RichHistoryContainer(props: Props) {
const [visible, setVisible] = useState(false);
const [height, setHeight] = useState(400);
/* To create sliding animation for rich history drawer */
useEffect(() => {
const timer = setTimeout(() => setVisible(true), 100);
const timer = setTimeout(() => setVisible(true), 10);
return () => clearTimeout(timer);
}, []);
const { richHistory, width, firstTab, activeDatasourceInstance, exploreId, deleteRichHistory } = props;
const { richHistory, width, firstTab, activeDatasourceInstance, exploreId, deleteRichHistory, onClose } = props;
const theme = useTheme();
const styles = getStyles(theme);
const drawerWidth = `${width + 31.5}px`;
@@ -118,6 +120,9 @@ function RichHistoryContainer(props: Props) {
maxHeight="100vh"
maxWidth={drawerWidth}
minWidth={drawerWidth}
onResize={(e, dir, ref) => {
setHeight(Number(ref.style.height.slice(0, -2)));
}}
>
<RichHistory
richHistory={richHistory}
@@ -125,6 +130,8 @@ function RichHistoryContainer(props: Props) {
activeDatasourceInstance={activeDatasourceInstance}
exploreId={exploreId}
deleteRichHistory={deleteRichHistory}
onClose={onClose}
height={height}
/>
</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
import { stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { getExploreDatasources } from '../state/selectors';
import { SortOrder } from 'app/core/utils/explore';
import {
@@ -16,6 +15,7 @@ import {
mapNumbertoTimeInSlider,
createRetentionPeriodBoundary,
mapQueriesToHeadings,
createDatasourcesList,
} from 'app/core/utils/richHistory';
// Components
@@ -23,22 +23,24 @@ import RichHistoryCard from './RichHistoryCard';
import { sortOrderOptions } from './RichHistory';
import { Select, Slider } from '@grafana/ui';
interface Props {
export interface Props {
queries: RichHistoryQuery[];
sortOrder: SortOrder;
activeDatasourceOnly: boolean;
datasourceFilters: SelectableValue[] | null;
retentionPeriod: number;
exploreId: ExploreId;
height: number;
onChangeSortOrder: (sortOrder: SortOrder) => 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;
/* 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 sliderHeight = `${height - 200}px`;
return {
container: css`
display: flex;
@@ -61,9 +63,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
margin-right: ${theme.spacing.sm};
.slider {
bottom: 10px;
height: 200px;
height: ${sliderHeight};
width: 127px;
padding: ${theme.spacing.xs} 0;
padding: ${theme.spacing.sm} 0;
}
`,
slider: css`
@@ -127,20 +129,15 @@ export function RichHistoryQueriesTab(props: Props) {
activeDatasourceOnly,
retentionPeriod,
exploreId,
height,
} = props;
const [sliderRetentionFilter, setSliderRetentionFilter] = useState<[number, number]>([0, retentionPeriod]);
const theme = useTheme();
const styles = getStyles(theme);
const listOfDsNamesWithQueries = uniqBy(queries, 'datasourceName').map(d => d.datasourceName);
/* 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 styles = getStyles(theme, height);
const datasourcesRetrievedFromQueryHistory = uniqBy(queries, 'datasourceName').map(d => d.datasourceName);
const listOfDatasources = createDatasourcesList(datasourcesRetrievedFromQueryHistory);
const listOfDatasourceFilters = datasourceFilters?.map(d => d.value);
const filteredQueriesByDatasource = datasourceFilters
@@ -187,18 +184,19 @@ export function RichHistoryQueriesTab(props: Props) {
<div className={styles.containerContent}>
<div className={styles.selectors}>
{!activeDatasourceOnly && (
<div className={styles.multiselect}>
<div aria-label="Filter datasources" className={styles.multiselect}>
<Select
isMulti={true}
options={datasources}
options={listOfDatasources}
value={datasourceFilters}
placeholder="Filter queries for specific datasources(s)"
placeholder="Filter queries for specific data sources(s)"
onChange={onSelectDatasourceFilters}
/>
</div>
)}
<div className={styles.sort}>
<div aria-label="Sort queries" className={styles.sort}>
<Select
value={sortOrderOptions.filter(order => order.value === sortOrder)}
options={sortOrderOptions}
placeholder="Sort queries by"
onChange={e => onChangeSortOrder(e.value as SortOrder)}
@@ -211,9 +209,18 @@ export function RichHistoryQueriesTab(props: Props) {
<div className={styles.heading}>
{heading} <span className={styles.queries}>{mappedQueriesToHeadings[heading].length} queries</span>
</div>
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => (
<RichHistoryCard query={q} key={q.ts} exploreId={exploreId} />
))}
{mappedQueriesToHeadings[heading].map((q: RichHistoryQuery) => {
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>
);
})}

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 { GrafanaTheme, AppEvents } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { CoreEvents } from 'app/types';
interface RichHistorySettingsProps {
export interface RichHistorySettingsProps {
retentionPeriod: number;
starredTabAsFirstTab: boolean;
activeDatasourceOnly: boolean;
@@ -57,6 +58,19 @@ export function RichHistorySettings(props: RichHistorySettingsProps) {
const styles = getStyles(theme);
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 (
<div className={styles.container}>
<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>
</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}>
<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>
</Forms.Field>
<div
@@ -98,13 +112,7 @@ export function RichHistorySettings(props: RichHistorySettingsProps) {
>
Delete all of your query history, permanently.
</div>
<Forms.Button
variant="destructive"
onClick={() => {
deleteRichHistory();
appEvents.emit(AppEvents.alertSuccess, ['Query history deleted']);
}}
>
<Forms.Button variant="destructive" onClick={onDelete}>
Clear query history
</Forms.Button>
</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
import { stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { getExploreDatasources } from '../state/selectors';
import { SortOrder } from '../../../core/utils/explore';
import { sortQueries } from '../../../core/utils/richHistory';
import { sortQueries, createDatasourcesList } from '../../../core/utils/richHistory';
// Components
import RichHistoryCard from './RichHistoryCard';
import { sortOrderOptions } from './RichHistory';
import { Select } from '@grafana/ui';
interface Props {
export interface Props {
queries: RichHistoryQuery[];
sortOrder: SortOrder;
activeDatasourceOnly: boolean;
@@ -33,17 +32,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
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`
width: 100%;
@@ -63,19 +51,18 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
sort: css`
width: 170px;
`,
sessionName: css`
display: flex;
align-items: flex-start;
justify-content: flex-start;
feedback: css`
height: 60px;
margin-top: ${theme.spacing.lg};
h4 {
margin: 0 10px 0 0;
display: flex;
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 styles = getStyles(theme);
const listOfDsNamesWithQueries = uniqBy(queries, 'datasourceName').map(d => d.datasourceName);
const exploreDatasources = getExploreDatasources()
?.filter(ds => listOfDsNamesWithQueries.includes(ds.name))
.map(d => {
return { value: d.value!, label: d.value!, imgUrl: d.meta.info.logos.small };
});
const datasourcesRetrievedFromQueryHistory = uniqBy(queries, 'datasourceName').map(d => d.datasourceName);
const listOfDatasources = createDatasourcesList(datasourcesRetrievedFromQueryHistory);
const listOfDatasourceFilters = datasourceFilters?.map(d => d.value);
const starredQueries = queries.filter(q => q.starred === true);
const starredQueriesFilteredByDatasource = datasourceFilters
? starredQueries?.filter(q => listOfDatasourceFilters?.includes(q.datasourceName))
: starredQueries;
const sortedStarredQueries = sortQueries(starredQueriesFilteredByDatasource, sortOrder);
return (
@@ -111,27 +97,41 @@ export function RichHistoryStarredTab(props: Props) {
<div className={styles.containerContent}>
<div className={styles.selectors}>
{!activeDatasourceOnly && (
<div className={styles.multiselect}>
<div aria-label="Filter datasources" className={styles.multiselect}>
<Select
isMulti={true}
options={exploreDatasources}
options={listOfDatasources}
value={datasourceFilters}
placeholder="Filter queries for specific datasources(s)"
placeholder="Filter queries for specific data sources(s)"
onChange={onSelectDatasourceFilters}
/>
</div>
)}
<div className={styles.sort}>
<div aria-label="Sort queries" className={styles.sort}>
<Select
options={sortOrderOptions}
value={sortOrderOptions.filter(order => order.value === sortOrder)}
placeholder="Sort queries by"
onChange={e => onChangeSortOrder(e.value as SortOrder)}
/>
</div>
</div>
{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>
);

View File

@@ -43,6 +43,7 @@ import {
deleteAllFromRichHistory,
updateStarredInRichHistory,
updateCommentInRichHistory,
deleteQueryInRichHistory,
getQueryDisplayText,
getRichHistory,
} from 'app/core/utils/richHistory';
@@ -439,20 +440,21 @@ export const runQueries = (exploreId: ExploreId): ThunkResult<void> => {
stopQueryState(querySubscription);
const datasourceId = datasourceInstance.meta.id;
const queryOptions: QueryOptions = {
minInterval,
// maxDataPoints is used in:
// 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.
// 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,
showingGraph,
showingTable,
mode,
};
const datasourceId = datasourceInstance.meta.id;
const datasourceName = exploreItemState.requestedDatasourceName;
const transaction = buildQueryTransaction(queries, queryOptions, range, scanning);
@@ -524,6 +526,9 @@ export const updateRichHistory = (ts: number, property: string, updatedProperty?
if (property === 'comment') {
nextRichHistory = updateCommentInRichHistory(getState().explore.richHistory, ts, updatedProperty);
}
if (property === 'delete') {
nextRichHistory = deleteQueryInRichHistory(getState().explore.richHistory, ts);
}
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 ticks from 'app/core/utils/ticks';
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 builtInPlugins from './built_in_plugins';
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/kbn', kbn);
exposeToPlugin('app/core/utils/ticks', ticks);
exposeToPlugin('app/core/utils/promiseToDigest', {
promiseToDigest: promiseToDigest,
__esModule: true,
});
exposeToPlugin('app/core/config', config);
exposeToPlugin('app/core/time_series', TimeSeries);

View File

@@ -3,7 +3,7 @@ import angular, { auto, ILocationService, IPromise, IQService } from 'angular';
import _ from 'lodash';
// Utils & Services
import coreModule from 'app/core/core_module';
import { variableTypes } from './variable';
import { VariableActions, variableTypes } from './variable';
import { Graph } from 'app/core/utils/dag';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
@@ -330,9 +330,12 @@ export class VariableSrv {
for (const v of this.variables) {
const key = `var-${v.name}`;
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) {
Promise.all(update).then(() => {
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() {
// update url
const params = this.$location.search();

View File

@@ -24,7 +24,23 @@ export default class AppInsightsDatasource {
constructor(instanceSettings: DataSourceInstanceSettings<AzureDataSourceJsonData>, private templateSrv: TemplateSrv) {
this.id = instanceSettings.id;
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;
}

View File

@@ -2,7 +2,7 @@ import AzureMonitorDatasource from '../datasource';
import FakeSchemaData from './__mocks__/schema';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { KustoSchema } from '../types';
import { KustoSchema, AzureLogsVariable } from '../types';
import { toUtc } from '@grafana/data';
import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__
@@ -283,53 +283,129 @@ describe('AzureLogAnalyticsDatasource', () => {
});
describe('When performing metricFindQuery', () => {
const tableResponseWithOneColumn = {
tables: [
{
name: 'PrimaryResult',
columns: [
{
name: 'Category',
type: 'string',
},
],
rows: [['Administrative'], ['Policy']],
},
],
};
let queryResults: AzureLogsVariable[];
const workspaceResponse = {
const workspacesResponse = {
value: [
{
name: 'aworkspace',
name: 'workspace1',
properties: {
source: 'Azure',
customerId: 'abc1b44e-3e57-4410-b027-6cc0ae6dee67',
customerId: 'eeee4fde-1aaa-4d60-9974-eeee562ffaa1',
},
},
{
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 () => {
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('workspaces()');
});
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', () => {
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');
describe('and is the workspaces() macro with the subscription parameter', () => {
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 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 LogAnalyticsQuerystringBuilder from '../log_analytics/querystring_builder';
import ResponseParser from './response_parser';
import { AzureMonitorQuery, AzureDataSourceJsonData } from '../types';
import { AzureMonitorQuery, AzureDataSourceJsonData, AzureLogsVariable } from '../types';
import { DataQueryRequest, DataSourceInstanceSettings } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { TemplateSrv } from 'app/features/templating/template_srv';
@@ -21,7 +21,20 @@ export default class AzureLogAnalyticsDatasource {
private templateSrv: TemplateSrv
) {
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.defaultOrFirstWorkspace = this.instanceSettings.jsonData.logAnalyticsDefaultWorkspace;
@@ -43,11 +56,23 @@ export default class AzureLogAnalyticsDatasource {
this.azureMonitorUrl = `/${azureCloud}/subscriptions`;
} else {
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 workspaceListUrl =
@@ -118,6 +143,16 @@ export default class AzureLogAnalyticsDatasource {
}
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) => {
const queries: any[] = this.buildQuery(query, null, workspace);

View File

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

View File

@@ -98,6 +98,15 @@
{ "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",
"method": "GET",
@@ -113,6 +122,21 @@
},
"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",
"method": "GET",
@@ -131,6 +155,25 @@
{ "name": "Cache-Control", "content": "public, max-age=60" },
{ "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 { QueryCtrl } from 'app/plugins/sdk';
import appEvents from 'app/core/app_events';
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
import { auto } from 'angular';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { AppEvents } from '@grafana/data';
@@ -59,7 +60,8 @@ export class GraphiteQueryCtrl extends QueryCtrl {
});
const checkOtherSegmentsIndex = this.queryModel.checkOtherSegmentsIndex || 0;
this.checkOtherSegments(checkOtherSegmentsIndex, modifyLastSegment);
promiseToDigest(this.$scope)(this.checkOtherSegments(checkOtherSegmentsIndex, modifyLastSegment));
if (this.queryModel.seriesByTagUsed) {
this.fixTagSegments();
@@ -73,12 +75,12 @@ export class GraphiteQueryCtrl extends QueryCtrl {
checkOtherSegments(fromIndex: number, modifyLastSegment = true) {
if (this.queryModel.segments.length === 1 && this.queryModel.segments[0].type === 'series-ref') {
return;
return Promise.resolve();
}
if (fromIndex === 0) {
this.addSelectMetricSegment();
return;
return Promise.resolve();
}
const path = this.queryModel.getSegmentPathUpTo(fromIndex + 1);
@@ -207,20 +209,24 @@ export class GraphiteQueryCtrl extends QueryCtrl {
const tag = removeTagPrefix(segment.value);
this.pause();
this.addSeriesByTagFunc(tag);
return;
return null;
}
if (segment.expandable) {
return this.checkOtherSegments(segmentIndex + 1).then(() => {
this.setSegmentFocus(segmentIndex + 1);
this.targetChanged();
});
return promiseToDigest(this.$scope)(
this.checkOtherSegments(segmentIndex + 1).then(() => {
this.setSegmentFocus(segmentIndex + 1);
this.targetChanged();
})
);
} else {
this.spliceSegments(segmentIndex + 1);
}
this.setSegmentFocus(segmentIndex + 1);
this.targetChanged();
return null;
}
spliceSegments(index: any) {

View File

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

View File

@@ -1,7 +1,7 @@
import cloneDeep from 'lodash/cloneDeep';
import groupBy from 'lodash/groupBy';
import { from, of, Observable, merge } from 'rxjs';
import { tap } from 'rxjs/operators';
import { from, of, Observable, forkJoin } from 'rxjs';
import { map, mergeMap, mergeAll } from 'rxjs/operators';
import {
LoadingState,
@@ -12,7 +12,6 @@ import {
DataSourceInstanceSettings,
} from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { mergeMap, map } from 'rxjs/operators';
export const MIXED_DATASOURCE_NAME = '-- Mixed --';
@@ -51,64 +50,46 @@ export class MixedDatasource extends DataSourceApi<DataQuery> {
}
batchQueries(mixed: BatchedQueries[], request: DataQueryRequest<DataQuery>): Observable<DataQueryResponse> {
const observables: Array<Observable<DataQueryResponse>> = [];
let runningSubRequests = 0;
const runningQueries = mixed.filter(this.isQueryable).map((query, i) =>
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++) {
const query = mixed[i];
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 from(api.query(dsRequest)).pipe(
map(response => {
return {
...response,
data: response.data || [],
state: runningSubRequests === 0 ? LoadingState.Done : LoadingState.Loading,
state: LoadingState.Loading,
key: `mixed-${i}-${response.key || ''}`,
} as DataQueryResponse;
})
);
})
);
)
);
observables.push(observable);
}
return merge(...observables);
return forkJoin(runningQueries).pipe(map(this.markAsDone), mergeAll());
}
testDatasource() {
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;
}
}

View File

@@ -1,6 +1,4 @@
import DatasourceSrv from 'app/features/plugins/datasource_srv';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import StackdriverDatasource from './datasource';
import { AuthType, authTypes } from './types';
export interface JWT {
@@ -21,10 +19,9 @@ export class StackdriverConfigCtrl {
authenticationTypes: Array<{ key: AuthType; value: string }>;
defaultAuthenticationType: string;
name: string;
gceError: string;
/** @ngInject */
constructor(datasourceSrv: DatasourceSrv, private $scope: any) {
constructor(datasourceSrv: DatasourceSrv) {
this.defaultAuthenticationType = AuthType.JWT;
this.datasourceSrv = datasourceSrv;
this.name = this.meta.name;
@@ -98,19 +95,4 @@ export class StackdriverConfigCtrl {
this.current.secureJsonData = {};
this.current.secureJsonFields = {};
}
async loadGCEDefaultAccount() {
this.gceError = '';
const ds = (await getDatasourceSrv().loadDatasource(this.name)) as StackdriverDatasource;
try {
const defaultProject = await ds.getGCEDefaultProject();
this.$scope.$apply(() => {
this.current.jsonData.gceDefaultProject = defaultProject;
});
} catch (error) {
this.$scope.$apply(() => {
this.gceError = error;
});
}
}
}

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