mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 16:54:59 +08:00
Compare commits
57 Commits
docs/updat
...
v6.7.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86c5fe3746 | ||
|
|
3ca75366b4 | ||
|
|
2718859d69 | ||
|
|
4277e4d177 | ||
|
|
8e44bbc5f5 | ||
|
|
7031b922c6 | ||
|
|
a04ef6cefc | ||
|
|
c5ea64c2c2 | ||
|
|
423a25fc32 | ||
|
|
86241d8bff | ||
|
|
670ee15dbd | ||
|
|
9abfbf18e0 | ||
|
|
882ed637c1 | ||
|
|
676972e798 | ||
|
|
3be4685589 | ||
|
|
aa227c5c20 | ||
|
|
ba26ac343b | ||
|
|
27a8112e06 | ||
|
|
a0a9ca220c | ||
|
|
0287819e36 | ||
|
|
16f3fe7e15 | ||
|
|
ca6d08d5cb | ||
|
|
d01bdb517d | ||
|
|
63dfdb7066 | ||
|
|
e95667fffb | ||
|
|
c08b901664 | ||
|
|
7cd6fef466 | ||
|
|
1b4f93b88c | ||
|
|
c4656a885d | ||
|
|
818a2f3d64 | ||
|
|
7f52e023b5 | ||
|
|
962a06545a | ||
|
|
79aeeaa10a | ||
|
|
ea483c0ce1 | ||
|
|
3e88197f96 | ||
|
|
66df54db80 | ||
|
|
e4b4480064 | ||
|
|
4e4f69b5f6 | ||
|
|
a4b7209e39 | ||
|
|
6c001d9c09 | ||
|
|
312600aa2c | ||
|
|
26d701dcf9 | ||
|
|
e347b62cee | ||
|
|
36232857df | ||
|
|
9d605bdd04 | ||
|
|
4d235b978e | ||
|
|
6575c9cb6e | ||
|
|
0ad27a6596 | ||
|
|
a0c6afa0a5 | ||
|
|
3d0bc141c7 | ||
|
|
ed307897e7 | ||
|
|
1d63f57caf | ||
|
|
e00f393a17 | ||
|
|
277e00aaed | ||
|
|
ba6104190e | ||
|
|
eaaca91f25 | ||
|
|
a551cd2470 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -308,7 +308,7 @@ Example connstr: `addr=127.0.0.1:6379,pool_size=100,db=0,ssl=false`
|
||||
|
||||
- `addr` is the host `:` port of the redis server.
|
||||
- `pool_size` (optional) is the number of underlying connections that can be made to redis.
|
||||
- `db` (optional) is the number indentifer of the redis database you want to use.
|
||||
- `db` (optional) is the numerical identifier of the redis database you want to use.
|
||||
- `ssl` (optional) is if SSL should be used to connect to redis server. The value may be `true`, `false`, or `insecure`. Setting the value to `insecure` skips verification of the certificate chain and hostname when making the connection.
|
||||
|
||||
#### Memcache
|
||||
|
||||
9
go.mod
9
go.mod
@@ -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
31
go.sum
@@ -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=
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"packages": ["packages/*"],
|
||||
"version": "6.7.0-pre"
|
||||
"version": "6.7.6"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"name": "grafana",
|
||||
"version": "6.7.0-pre",
|
||||
"version": "6.7.6",
|
||||
"repository": "github:grafana/grafana",
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.8.4",
|
||||
@@ -186,6 +186,10 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"watch": "yarn start -d watch,start core:start --watchTheme "
|
||||
},
|
||||
"grafana": {
|
||||
"whatsNewUrl": "https://grafana.com/docs/grafana/latest/guides/whats-new-in-v6-7/",
|
||||
"releaseNotesUrl": "https://community.grafana.com/t/release-notes-v6-7-x/"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged && npm run precommit"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/data",
|
||||
"version": "6.7.0-pre",
|
||||
"version": "6.7.6",
|
||||
"description": "Grafana Data Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"module": "commonjs",
|
||||
"types": ["cypress"]
|
||||
},
|
||||
"extends": "../tsconfig.json",
|
||||
"extends": "@grafana/tsconfig",
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e",
|
||||
"version": "6.7.0-pre",
|
||||
"version": "6.7.6",
|
||||
"description": "Grafana End-to-End Test Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/runtime",
|
||||
"version": "6.7.0-pre",
|
||||
"version": "6.7.6",
|
||||
"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.6",
|
||||
"@grafana/ui": "6.7.6",
|
||||
"systemjs": "0.20.19",
|
||||
"systemjs-plugin-css": "0.1.37"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/toolkit",
|
||||
"version": "6.7.0-pre",
|
||||
"version": "6.7.6",
|
||||
"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.6",
|
||||
"@grafana/eslint-config": "^1.0.0-rc1",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@grafana/ui": "6.7.0-pre",
|
||||
"@grafana/ui": "6.7.6",
|
||||
"@types/command-exists": "^1.2.0",
|
||||
"@types/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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/ui",
|
||||
"version": "6.7.0-pre",
|
||||
"version": "6.7.6",
|
||||
"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.6",
|
||||
"@grafana/slate-react": "0.22.9-grafana",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@torkelo/react-select": "3.0.8",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -14,7 +14,6 @@ export const IndicatorsContainer = React.forwardRef<HTMLDivElement, React.PropsW
|
||||
styles.suffix,
|
||||
css`
|
||||
position: relative;
|
||||
top: 1px;
|
||||
`
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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};
|
||||
`
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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};
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("*"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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", "/")
|
||||
|
||||
@@ -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...,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func NewRendererPluginDescriptor(pluginID, executablePath string, startFns Plugi
|
||||
}
|
||||
|
||||
type DiagnosticsPlugin interface {
|
||||
plugin.DiagnosticsServer
|
||||
plugin.DiagnosticsClient
|
||||
}
|
||||
|
||||
type ResourcePlugin interface {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
102
public/app/core/utils/fetch.test.ts
Normal file
102
public/app/core/utils/fetch.test.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
});
|
||||
116
public/app/core/utils/fetch.ts
Normal file
116
public/app/core/utils/fetch.ts
Normal 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;
|
||||
};
|
||||
181
public/app/core/utils/richHistory.test.ts
Normal file
181
public/app/core/utils/richHistory.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
63
public/app/features/explore/RichHistory/RichHistory.test.tsx
Normal file
63
public/app/features/explore/RichHistory/RichHistory.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
115
public/app/features/explore/RichHistory/RichHistoryCard.test.tsx
Normal file
115
public/app/features/explore/RichHistory/RichHistoryCard.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user