mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'origin/master' into develop
This commit is contained in:
commit
beb9f8ee74
2
.gitignore
vendored
2
.gitignore
vendored
@ -39,6 +39,8 @@ conf/custom.ini
|
||||
fig.yml
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
/conf/dashboards/custom.yaml
|
||||
/conf/datasources/custom.yaml
|
||||
profile.cov
|
||||
/grafana
|
||||
.notouch
|
||||
|
@ -24,7 +24,7 @@
|
||||
* **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda)
|
||||
* **Dashboard**: Make it possible to start dashboards from search and dashboard list panel [#1871](https://github.com/grafana/grafana/issues/1871)
|
||||
* **Annotations**: Posting annotations now return the id of the annotation [#9798](https://github.com/grafana/grafana/issues/9798)
|
||||
|
||||
* **Systemd**: Use systemd notification ready flag [#10024](https://github.com/grafana/grafana/issues/10024), thx [@jgrassler](https://github.com/jgrassler)
|
||||
## Tech
|
||||
* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
|
||||
|
||||
|
208
LICENSE.md
208
LICENSE.md
@ -1,14 +1,202 @@
|
||||
Copyright 2014-2017 Torkel Ödegaard, Raintank Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you
|
||||
may not use this file except in compliance with the License. You may
|
||||
obtain a copy of the License at
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied. See the License for the specific language governing
|
||||
permissions and limitations under the License.
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
16
NOTICE.md
16
NOTICE.md
@ -1,16 +1,6 @@
|
||||
|
||||
This software is based on Kibana:
|
||||
========================================
|
||||
Copyright 2014-2017 Grafana Labs
|
||||
|
||||
This software is based on Kibana:
|
||||
Copyright 2012-2013 Elasticsearch BV
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you
|
||||
may not use this file except in compliance with the License. You may
|
||||
obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied. See the License for the specific language governing
|
||||
permissions and limitations under the License.
|
||||
|
6
build.go
6
build.go
@ -95,9 +95,9 @@ func main() {
|
||||
|
||||
case "package":
|
||||
grunt(gruntBuildArg("release")...)
|
||||
if runtime.GOOS != "windows" {
|
||||
createLinuxPackages()
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
createLinuxPackages()
|
||||
}
|
||||
|
||||
case "pkg-rpm":
|
||||
grunt(gruntBuildArg("release")...)
|
||||
|
@ -7,3 +7,4 @@
|
||||
MYSQL_PASSWORD: password
|
||||
ports:
|
||||
- "3306:3306"
|
||||
tmpfs: /var/lib/mysql:rw
|
||||
|
@ -5,3 +5,4 @@
|
||||
POSTGRES_PASSWORD: grafanatest
|
||||
ports:
|
||||
- "5432:5432"
|
||||
tmpfs: /var/lib/postgresql/data:rw
|
@ -65,13 +65,14 @@ Currently we do not provide any scripts/manifests for configuring Grafana. Rathe
|
||||
Tool | Project
|
||||
-----|------------
|
||||
Puppet | [https://forge.puppet.com/puppet/grafana](https://forge.puppet.com/puppet/grafana)
|
||||
Ansible | [https://github.com/cloudalchemy/ansible-grafana](https://github.com/cloudalchemy/ansible-grafana)
|
||||
Ansible | [https://github.com/picotrading/ansible-grafana](https://github.com/picotrading/ansible-grafana)
|
||||
Chef | [https://github.com/JonathanTron/chef-grafana](https://github.com/JonathanTron/chef-grafana)
|
||||
Saltstack | [https://github.com/salt-formulas/salt-formula-grafana](https://github.com/salt-formulas/salt-formula-grafana)
|
||||
|
||||
## Datasources
|
||||
|
||||
> This feature is available from v4.7
|
||||
> This feature is available from v5.0
|
||||
|
||||
It's possible to manage datasources in Grafana by adding one or more yaml config files in the [`conf/datasources`](/installation/configuration/#datasources) directory. Each config file can contain a list of `datasources` that will be added or updated during start up. If the datasource already exists, Grafana will update it to match the configuration file. The config file can also contain a list of datasources that should be deleted. That list is called `delete_datasources`. Grafana will delete datasources listed in `delete_datasources` before inserting/updating those in the `datasource` list.
|
||||
|
||||
|
@ -127,6 +127,12 @@ A query can returns multiple columns and Grafana will automatically create a lis
|
||||
SELECT my_host.hostname, my_other_host.hostname2 FROM my_host JOIN my_other_host ON my_host.city = my_other_host.city
|
||||
```
|
||||
|
||||
To use time range dependent macros like `$__timeFilter(column)` in your query the refresh mode of the template variable needs to be set to *On Time Range Change*.
|
||||
|
||||
```sql
|
||||
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
|
||||
```
|
||||
|
||||
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
|
||||
|
||||
```sql
|
||||
|
@ -139,6 +139,12 @@ A query can return multiple columns and Grafana will automatically create a list
|
||||
SELECT host.hostname, other_host.hostname2 FROM host JOIN other_host ON host.city = other_host.city
|
||||
```
|
||||
|
||||
To use time range dependent macros like `$__timeFilter(column)` in your query the refresh mode of the template variable needs to be set to *On Time Range Change*.
|
||||
|
||||
```sql
|
||||
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
|
||||
```
|
||||
|
||||
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column value should be unique (if it is not unique then the first value is used). The options in the dropdown will have a text and value that allows you to have a friendly name as text and an id as the value. An example query with `hostname` as the text and `id` as the value:
|
||||
|
||||
```sql
|
||||
|
@ -93,7 +93,10 @@ Directory where grafana will automatically scan and look for plugins
|
||||
|
||||
### datasources
|
||||
|
||||
Config files containing datasources that will be configured at startup
|
||||
> This feature is available in 5.0+
|
||||
|
||||
Config files containing datasources that will be configured at startup.
|
||||
You can read more about the config files at the [provisioning page](/administration/provisioning/#datasources).
|
||||
|
||||
## [server]
|
||||
|
||||
|
@ -9,7 +9,7 @@ After=postgresql.service mariadb.service mysql.service
|
||||
EnvironmentFile=/etc/sysconfig/grafana-server
|
||||
User=grafana
|
||||
Group=grafana
|
||||
Type=simple
|
||||
Type=notify
|
||||
Restart=on-failure
|
||||
WorkingDirectory=/usr/share/grafana
|
||||
RuntimeDirectory=grafana
|
||||
|
@ -10,7 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func RenderToPng(c *middleware.Context) {
|
||||
queryReader := util.NewUrlQueryReader(c.Req.URL)
|
||||
queryReader, err := util.NewUrlQueryReader(c.Req.URL)
|
||||
if err != nil {
|
||||
c.Handle(400, "Render parameters error", err)
|
||||
return
|
||||
}
|
||||
queryParams := fmt.Sprintf("?%s", c.Req.URL.RawQuery)
|
||||
|
||||
renderOpts := &renderer.RenderOpts{
|
||||
|
@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@ -96,6 +98,7 @@ func (g *GrafanaServerImpl) Start() {
|
||||
return
|
||||
}
|
||||
|
||||
SendSystemdNotification("READY=1")
|
||||
g.startHttpServer()
|
||||
}
|
||||
|
||||
@ -169,3 +172,28 @@ func (g *GrafanaServerImpl) writePIDFile() {
|
||||
|
||||
g.log.Info("Writing PID file", "path", *pidFile, "pid", pid)
|
||||
}
|
||||
|
||||
func SendSystemdNotification(state string) error {
|
||||
notifySocket := os.Getenv("NOTIFY_SOCKET")
|
||||
|
||||
if notifySocket == "" {
|
||||
return fmt.Errorf("NOTIFY_SOCKET environment variable empty or unset.")
|
||||
}
|
||||
|
||||
socketAddr := &net.UnixAddr{
|
||||
Name: notifySocket,
|
||||
Net: "unixgram",
|
||||
}
|
||||
|
||||
conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = conn.Write([]byte(state))
|
||||
|
||||
conn.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ func TestLogFile(t *testing.T) {
|
||||
So(fileLogWrite.maxlines_curlines, ShouldEqual, 3)
|
||||
})
|
||||
|
||||
fileLogWrite.Close()
|
||||
err = os.Remove(fileLogWrite.Filename)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
@ -366,7 +366,6 @@ func TestAlertRuleExtraction(t *testing.T) {
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"dsType": "influxdb",
|
||||
"groupBy": [
|
||||
{
|
||||
"params": [
|
||||
@ -411,7 +410,6 @@ func TestAlertRuleExtraction(t *testing.T) {
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"dsType": "influxdb",
|
||||
"groupBy": [
|
||||
{
|
||||
"params": [
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
@ -176,7 +177,7 @@ func (this *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
|
||||
func SlackFileUpload(evalContext *alerting.EvalContext, log log.Logger, url string, recipient string, token string) error {
|
||||
if evalContext.ImageOnDiskPath == "" {
|
||||
evalContext.ImageOnDiskPath = "public/img/mixed_styles.png"
|
||||
evalContext.ImageOnDiskPath = filepath.Join(setting.HomePath, "public/img/mixed_styles.png")
|
||||
}
|
||||
log.Info("Uploading to slack via file.upload API")
|
||||
headers, uploadBody, err := GenerateSlackBody(evalContext.ImageOnDiskPath, token, recipient)
|
||||
|
@ -401,7 +401,7 @@ func SearchUsers(query *m.SearchUsersQuery) error {
|
||||
}
|
||||
|
||||
if query.Query != "" {
|
||||
whereConditions = append(whereConditions, "(email LIKE ? OR name LIKE ? OR login like ?)")
|
||||
whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")
|
||||
whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb"
|
||||
opentracing "github.com/opentracing/opentracing-go"
|
||||
"github.com/opentracing/opentracing-go"
|
||||
)
|
||||
|
||||
type GraphiteExecutor struct {
|
||||
@ -158,7 +158,7 @@ func formatTimeRange(input string) string {
|
||||
if input == "now" {
|
||||
return input
|
||||
}
|
||||
return strings.Replace(strings.Replace(input, "m", "min", -1), "M", "mon", -1)
|
||||
return strings.Replace(strings.Replace(strings.Replace(input, "now", "", -1), "m", "min", -1), "M", "mon", -1)
|
||||
}
|
||||
|
||||
func fixIntervalFormat(target string) string {
|
||||
|
@ -18,14 +18,14 @@ func TestGraphiteFunctions(t *testing.T) {
|
||||
Convey("formatting time range for now-1m", func() {
|
||||
|
||||
timeRange := formatTimeRange("now-1m")
|
||||
So(timeRange, ShouldEqual, "now-1min")
|
||||
So(timeRange, ShouldEqual, "-1min")
|
||||
|
||||
})
|
||||
|
||||
Convey("formatting time range for now-1M", func() {
|
||||
|
||||
timeRange := formatTimeRange("now-1M")
|
||||
So(timeRange, ShouldEqual, "now-1mon")
|
||||
So(timeRange, ShouldEqual, "-1mon")
|
||||
|
||||
})
|
||||
|
||||
|
@ -20,7 +20,6 @@ func TestInfluxdbQueryParser(t *testing.T) {
|
||||
Convey("can parse influxdb json model", func() {
|
||||
json := `
|
||||
{
|
||||
"dsType": "influxdb",
|
||||
"groupBy": [
|
||||
{
|
||||
"params": [
|
||||
@ -123,7 +122,6 @@ func TestInfluxdbQueryParser(t *testing.T) {
|
||||
Convey("can part raw query json model", func() {
|
||||
json := `
|
||||
{
|
||||
"dsType": "influxdb",
|
||||
"groupBy": [
|
||||
{
|
||||
"params": [
|
||||
|
@ -50,6 +50,7 @@ func (rp *ResponseParser) transformRows(rows []Row, queryResult *tsdb.QueryResul
|
||||
result = append(result, &tsdb.TimeSeries{
|
||||
Name: rp.formatSerieName(row, column, query),
|
||||
Points: points,
|
||||
Tags: row.Tags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -78,6 +78,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
|
||||
|
||||
rowLimit := 1000000
|
||||
rowCount := 0
|
||||
timeIndex := -1
|
||||
|
||||
// check if there is a column named time
|
||||
for i, col := range columnNames {
|
||||
switch col {
|
||||
case "time":
|
||||
timeIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
for ; rows.Next(); rowCount++ {
|
||||
if rowCount > rowLimit {
|
||||
@ -89,6 +98,15 @@ func (e PostgresQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Ro
|
||||
return err
|
||||
}
|
||||
|
||||
// convert column named time to unix timestamp to make
|
||||
// native datetime postgres types work in annotation queries
|
||||
if timeIndex != -1 {
|
||||
switch value := values[timeIndex].(type) {
|
||||
case time.Time:
|
||||
values[timeIndex] = float64(value.UnixNano() / 1e9)
|
||||
}
|
||||
}
|
||||
|
||||
table.Rows = append(table.Rows, values)
|
||||
}
|
||||
|
||||
@ -142,8 +160,13 @@ func (e PostgresQueryEndpoint) getTypedRowData(rows *core.Rows) (tsdb.RowValues,
|
||||
func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.Rows, result *tsdb.QueryResult) error {
|
||||
pointsBySeries := make(map[string]*tsdb.TimeSeries)
|
||||
seriesByQueryOrder := list.New()
|
||||
columnNames, err := rows.Columns()
|
||||
|
||||
columnNames, err := rows.Columns()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
columnTypes, err := rows.ColumnTypes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -153,13 +176,21 @@ func (e PostgresQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *co
|
||||
timeIndex := -1
|
||||
metricIndex := -1
|
||||
|
||||
// check columns of resultset
|
||||
// check columns of resultset: a column named time is mandatory
|
||||
// the first text column is treated as metric name unless a column named metric is present
|
||||
for i, col := range columnNames {
|
||||
switch col {
|
||||
case "time":
|
||||
timeIndex = i
|
||||
case "metric":
|
||||
metricIndex = i
|
||||
default:
|
||||
if metricIndex == -1 {
|
||||
switch columnTypes[i].DatabaseTypeName() {
|
||||
case "UNKNOWN", "TEXT", "VARCHAR", "CHAR":
|
||||
metricIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,10 +9,15 @@ type UrlQueryReader struct {
|
||||
values url.Values
|
||||
}
|
||||
|
||||
func NewUrlQueryReader(url *url.URL) *UrlQueryReader {
|
||||
return &UrlQueryReader{
|
||||
values: url.Query(),
|
||||
func NewUrlQueryReader(urlInfo *url.URL) (*UrlQueryReader, error) {
|
||||
u, err := url.ParseQuery(urlInfo.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UrlQueryReader{
|
||||
values: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *UrlQueryReader) Get(name string, def string) string {
|
||||
|
@ -17,6 +17,10 @@ class Settings {
|
||||
alertingEnabled: boolean;
|
||||
authProxyEnabled: boolean;
|
||||
ldapEnabled: boolean;
|
||||
oauth: any;
|
||||
disableUserSignUp: boolean;
|
||||
loginHint: any;
|
||||
loginError: any;
|
||||
|
||||
constructor(options) {
|
||||
var defaults = {
|
||||
|
@ -1,9 +0,0 @@
|
||||
define([
|
||||
'./inspect_ctrl',
|
||||
'./json_editor_ctrl',
|
||||
'./login_ctrl',
|
||||
'./invited_ctrl',
|
||||
'./signup_ctrl',
|
||||
'./reset_password_ctrl',
|
||||
'./error_ctrl',
|
||||
], function () {});
|
7
public/app/core/controllers/all.ts
Normal file
7
public/app/core/controllers/all.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import './inspect_ctrl';
|
||||
import './json_editor_ctrl';
|
||||
import './login_ctrl';
|
||||
import './invited_ctrl';
|
||||
import './signup_ctrl';
|
||||
import './reset_password_ctrl';
|
||||
import './error_ctrl';
|
@ -1,13 +1,10 @@
|
||||
define([
|
||||
'angular',
|
||||
'app/core/config',
|
||||
'../core_module',
|
||||
],
|
||||
function (angular, config, coreModule) {
|
||||
'use strict';
|
||||
import config from 'app/core/config';
|
||||
import coreModule from '../core_module';
|
||||
|
||||
coreModule.default.controller('ErrorCtrl', function($scope, contextSrv, navModelSrv) {
|
||||
export class ErrorCtrl {
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, contextSrv, navModelSrv) {
|
||||
$scope.navModel = navModelSrv.getNotFoundNav();
|
||||
$scope.appSubUrl = config.appSubUrl;
|
||||
|
||||
@ -17,7 +14,7 @@ function (angular, config, coreModule) {
|
||||
$scope.$on('$destroy', function() {
|
||||
contextSrv.sidemenu = showSideMenu;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
coreModule.controller('ErrorCtrl', ErrorCtrl);
|
@ -1,14 +1,10 @@
|
||||
define([
|
||||
'angular',
|
||||
'../core_module',
|
||||
'app/core/config',
|
||||
],
|
||||
function (angular, coreModule, config) {
|
||||
'use strict';
|
||||
import coreModule from '../core_module';
|
||||
import config from 'app/core/config';
|
||||
|
||||
config = config.default;
|
||||
export class InvitedCtrl {
|
||||
|
||||
coreModule.default.controller('InvitedCtrl', function($scope, $routeParams, contextSrv, backendSrv) {
|
||||
/** @ngInject */
|
||||
constructor($scope, $routeParams, contextSrv, backendSrv) {
|
||||
contextSrv.sidemenu = false;
|
||||
$scope.formModel = {};
|
||||
|
||||
@ -35,6 +31,7 @@ function (angular, coreModule, config) {
|
||||
};
|
||||
|
||||
$scope.init();
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
coreModule.controller('InvitedCtrl', InvitedCtrl);
|
@ -1,12 +1,10 @@
|
||||
define([
|
||||
'angular',
|
||||
'../core_module',
|
||||
],
|
||||
function (angular, coreModule) {
|
||||
'use strict';
|
||||
import angular from 'angular';
|
||||
import coreModule from '../core_module';
|
||||
|
||||
coreModule.default.controller('JsonEditorCtrl', function($scope) {
|
||||
export class JsonEditorCtrl {
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope) {
|
||||
$scope.json = angular.toJson($scope.object, true);
|
||||
$scope.canUpdate = $scope.updateHandler !== void 0 && $scope.contextSrv.isEditor;
|
||||
|
||||
@ -14,7 +12,7 @@ function (angular, coreModule) {
|
||||
var newObject = angular.fromJson($scope.json);
|
||||
$scope.updateHandler(newObject, $scope.object);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
coreModule.controller('JsonEditorCtrl', JsonEditorCtrl);
|
@ -1,15 +1,11 @@
|
||||
define([
|
||||
'angular',
|
||||
'lodash',
|
||||
'../core_module',
|
||||
'app/core/config',
|
||||
],
|
||||
function (angular, _, coreModule, config) {
|
||||
'use strict';
|
||||
import _ from 'lodash';
|
||||
import coreModule from '../core_module';
|
||||
import config from 'app/core/config';
|
||||
|
||||
config = config.default;
|
||||
export class LoginCtrl {
|
||||
|
||||
coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) {
|
||||
/** @ngInject */
|
||||
constructor($scope, backendSrv, contextSrv, $location) {
|
||||
$scope.formModel = {
|
||||
user: '',
|
||||
email: '',
|
||||
@ -74,8 +70,7 @@ function (angular, _, coreModule, config) {
|
||||
|
||||
if (params.redirect && params.redirect[0] === '/') {
|
||||
window.location.href = config.appSubUrl + params.redirect;
|
||||
}
|
||||
else if (result.redirectUrl) {
|
||||
} else if (result.redirectUrl) {
|
||||
window.location.href = result.redirectUrl;
|
||||
} else {
|
||||
window.location.href = config.appSubUrl + '/';
|
||||
@ -84,5 +79,7 @@ function (angular, _, coreModule, config) {
|
||||
};
|
||||
|
||||
$scope.init();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.controller('LoginCtrl', LoginCtrl);
|
@ -1,11 +1,9 @@
|
||||
define([
|
||||
'angular',
|
||||
'../core_module',
|
||||
],
|
||||
function (angular, coreModule) {
|
||||
'use strict';
|
||||
import coreModule from '../core_module';
|
||||
|
||||
coreModule.default.controller('ResetPasswordCtrl', function($scope, contextSrv, backendSrv, $location) {
|
||||
export class ResetPasswordCtrl {
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, contextSrv, backendSrv, $location) {
|
||||
contextSrv.sidemenu = false;
|
||||
$scope.formModel = {};
|
||||
$scope.mode = 'send';
|
||||
@ -37,7 +35,7 @@ function (angular, coreModule) {
|
||||
$location.path('login');
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
coreModule.controller('ResetPasswordCtrl', ResetPasswordCtrl);
|
@ -26,7 +26,7 @@ export class DashExportCtrl {
|
||||
}
|
||||
|
||||
saveJson() {
|
||||
var clone = this.dashboardSrv.getCurrent().getSaveModelClone();
|
||||
var clone = this.dash;
|
||||
|
||||
this.$scope.$root.appEvent('show-json-editor', {
|
||||
object: clone,
|
||||
|
@ -1,18 +1,11 @@
|
||||
define(['angular',
|
||||
'lodash',
|
||||
'jquery',
|
||||
'moment',
|
||||
'app/core/config',
|
||||
],
|
||||
function (angular, _, $, moment, config) {
|
||||
'use strict';
|
||||
import angular from 'angular';
|
||||
import moment from 'moment';
|
||||
import config from 'app/core/config';
|
||||
|
||||
config = config.default;
|
||||
|
||||
var module = angular.module('grafana.controllers');
|
||||
|
||||
module.controller('ShareModalCtrl', function($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
|
||||
export class ShareModalCtrl {
|
||||
|
||||
/** @ngInject */
|
||||
constructor($scope, $rootScope, $location, $timeout, timeSrv, templateSrv, linkSrv) {
|
||||
$scope.options = { forCurrent: true, includeTemplateVars: true, theme: 'current' };
|
||||
$scope.editor = { index: $scope.tabIndex || 0};
|
||||
|
||||
@ -93,7 +86,7 @@ function (angular, _, $, moment, config) {
|
||||
$scope.getShareUrl = function() {
|
||||
return $scope.shareUrl;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
angular.module('grafana.controllers').controller('ShareModalCtrl', ShareModalCtrl);
|
@ -172,7 +172,6 @@ export class ElasticQueryBuilder {
|
||||
build(target, adhocFilters?, queryString?) {
|
||||
// make sure query has defaults;
|
||||
target.metrics = target.metrics || [{ type: 'count', id: '1' }];
|
||||
target.dsType = 'elasticsearch';
|
||||
target.bucketAggs = target.bucketAggs || [{type: 'date_histogram', id: '2', settings: {interval: 'auto'}}];
|
||||
target.timeField = this.timeField;
|
||||
|
||||
|
@ -19,7 +19,6 @@ export default class InfluxQuery {
|
||||
this.scopedVars = scopedVars;
|
||||
|
||||
target.policy = target.policy || 'default';
|
||||
target.dsType = 'influxdb';
|
||||
target.resultFormat = target.resultFormat || 'time_series';
|
||||
target.orderByTime = target.orderByTime || 'ASC';
|
||||
target.tags = target.tags || [];
|
||||
|
@ -103,12 +103,21 @@ export class MysqlDatasource {
|
||||
format: 'table',
|
||||
};
|
||||
|
||||
var data = {
|
||||
queries: [interpolatedQuery],
|
||||
};
|
||||
|
||||
if (optionalOptions && optionalOptions.range && optionalOptions.range.from) {
|
||||
data['from'] = optionalOptions.range.from.valueOf().toString();
|
||||
}
|
||||
if (optionalOptions && optionalOptions.range && optionalOptions.range.to) {
|
||||
data['to'] = optionalOptions.range.to.valueOf().toString();
|
||||
}
|
||||
|
||||
return this.backendSrv.datasourceRequest({
|
||||
url: '/api/tsdb/query',
|
||||
method: 'POST',
|
||||
data: {
|
||||
queries: [interpolatedQuery],
|
||||
}
|
||||
data: data
|
||||
})
|
||||
.then(data => this.responseParser.parseMetricFindQueryResult(refId, data));
|
||||
}
|
||||
|
@ -99,12 +99,21 @@ export class PostgresDatasource {
|
||||
format: 'table',
|
||||
};
|
||||
|
||||
var data = {
|
||||
queries: [interpolatedQuery],
|
||||
};
|
||||
|
||||
if (optionalOptions && optionalOptions.range && optionalOptions.range.from) {
|
||||
data['from'] = optionalOptions.range.from.valueOf().toString();
|
||||
}
|
||||
if (optionalOptions && optionalOptions.range && optionalOptions.range.to) {
|
||||
data['to'] = optionalOptions.range.to.valueOf().toString();
|
||||
}
|
||||
|
||||
return this.backendSrv.datasourceRequest({
|
||||
url: '/api/tsdb/query',
|
||||
method: 'POST',
|
||||
data: {
|
||||
queries: [interpolatedQuery],
|
||||
}
|
||||
data: data
|
||||
})
|
||||
.then(data => this.responseParser.parseMetricFindQueryResult(refId, data));
|
||||
}
|
||||
|
@ -67,8 +67,8 @@ export default class PrometheusMetricFindQuery {
|
||||
|
||||
return this.datasource._request("GET", url).then(function(result) {
|
||||
var _labels = _.map(result.data.data, function(metric) {
|
||||
return metric[label];
|
||||
});
|
||||
return metric[label] || '';
|
||||
}).filter(function(label) { return label !== ''; });
|
||||
|
||||
return _.uniq(_labels).map(function(metric) {
|
||||
return {
|
||||
|
@ -1,63 +1,60 @@
|
||||
<query-editor-row query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="false">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form gf-form--grow">
|
||||
<code-editor content="ctrl.target.expr" datasource="ctrl.datasource" on-change="ctrl.refreshMetricData()"
|
||||
get-completer="ctrl.getCompleter()" data-mode="prometheus" code-editor-focus="ctrl.isLastQuery">
|
||||
</code-editor>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form gf-form--grow">
|
||||
<code-editor content="ctrl.target.expr" datasource="ctrl.datasource" on-change="ctrl.refreshMetricData()" get-completer="ctrl.getCompleter()"
|
||||
data-mode="prometheus" code-editor-focus="ctrl.isLastQuery">
|
||||
</code-editor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form max-width-26">
|
||||
<label class="gf-form-label width-8">Legend format</label>
|
||||
<input type="text" class="gf-form-input" ng-model="ctrl.target.legendFormat"
|
||||
spellcheck='false' placeholder="legend format" data-min-length=0 data-items=1000
|
||||
ng-model-onblur ng-change="ctrl.refreshMetricData()">
|
||||
</input>
|
||||
</div>
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form max-width-26">
|
||||
<label class="gf-form-label width-8">Legend format</label>
|
||||
<input type="text" class="gf-form-input" ng-model="ctrl.target.legendFormat" spellcheck='false' placeholder="legend format"
|
||||
data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()">
|
||||
</input>
|
||||
<info-popover mode="right-absolute">
|
||||
Controls the name of the time series, using name or pattern. For example {{hostname}} will be replaced with label value for
|
||||
the label hostname.
|
||||
</info-popover>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-6">Min step</label>
|
||||
<input type="text" class="gf-form-input width-8" ng-model="ctrl.target.interval"
|
||||
data-placement="right"
|
||||
spellcheck='false'
|
||||
placeholder="{{ctrl.panelCtrl.interval}}"
|
||||
data-min-length=0 data-items=100
|
||||
ng-model-onblur
|
||||
ng-change="ctrl.refreshMetricData()"/>
|
||||
<info-popover mode="right-absolute">
|
||||
Leave blank for auto handling based on time range and panel width
|
||||
</info-popover>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-6">Min step</label>
|
||||
<input type="text" class="gf-form-input width-8" ng-model="ctrl.target.interval" data-placement="right" spellcheck='false'
|
||||
placeholder="{{ctrl.panelCtrl.interval}}" data-min-length=0 data-items=100 ng-model-onblur ng-change="ctrl.refreshMetricData()"
|
||||
/>
|
||||
<info-popover mode="right-absolute">
|
||||
Leave blank for auto handling based on time range and panel width
|
||||
</info-popover>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label">Resolution</label>
|
||||
<div class="gf-form-select-wrapper max-width-15">
|
||||
<select ng-model="ctrl.target.intervalFactor" class="gf-form-input"
|
||||
ng-options="r.factor as r.label for r in ctrl.resolutions"
|
||||
ng-change="ctrl.refreshMetricData()">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-6">Format as</label>
|
||||
<div class="gf-form-select-wrapper width-8">
|
||||
<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.format" ng-options="f.value as f.text for f in ctrl.formats" ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
<gf-form-switch class="gf-form" label="Instant" label-class="width-5" checked="ctrl.target.instant" on-change="ctrl.refresh()">
|
||||
</gf-form-switch>
|
||||
<label class="gf-form-label">
|
||||
<a href="{{ctrl.linkToPrometheus}}" target="_blank" bs-tooltip="'Link to Graph in Prometheus'">
|
||||
<i class="fa fa-share-square-o"></i>
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label">Resolution</label>
|
||||
<div class="gf-form-select-wrapper max-width-15">
|
||||
<select ng-model="ctrl.target.intervalFactor" class="gf-form-input" ng-options="r.factor as r.label for r in ctrl.resolutions"
|
||||
ng-change="ctrl.refreshMetricData()">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label width-6">Format as</label>
|
||||
<div class="gf-form-select-wrapper width-8">
|
||||
<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.format" ng-options="f.value as f.text for f in ctrl.formats"
|
||||
ng-change="ctrl.refresh()"></select>
|
||||
</div>
|
||||
<gf-form-switch class="gf-form" label="Instant" label-class="width-5" checked="ctrl.target.instant" on-change="ctrl.refresh()">
|
||||
</gf-form-switch>
|
||||
<label class="gf-form-label">
|
||||
<a href="{{ctrl.linkToPrometheus}}" target="_blank" bs-tooltip="'Link to Graph in Prometheus'">
|
||||
<i class="fa fa-share-square-o"></i>
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="gf-form gf-form--grow">
|
||||
<div class="gf-form-label gf-form-label--grow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</query-editor-row>
|
||||
|
@ -13,6 +13,10 @@
|
||||
"alerting": true,
|
||||
"annotations": true,
|
||||
|
||||
"queryOptions": {
|
||||
"minInterval": true
|
||||
},
|
||||
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Project",
|
||||
|
@ -76,6 +76,24 @@ describe('PrometheusMetricFindQuery', function() {
|
||||
ctx.$rootScope.$apply();
|
||||
expect(results.length).to.be(3);
|
||||
});
|
||||
it('label_values(metric, resource) result should not contain empty string', function() {
|
||||
response = {
|
||||
status: "success",
|
||||
data: [
|
||||
{__name__: "metric", resource: "value1"},
|
||||
{__name__: "metric", resource: "value2"},
|
||||
{__name__: "metric", resource: ""}
|
||||
]
|
||||
};
|
||||
ctx.$httpBackend.expect('GET', /proxied\/api\/v1\/series\?match\[\]=metric&start=.*&end=.*/).respond(response);
|
||||
var pm = new PrometheusMetricFindQuery(ctx.ds, 'label_values(metric, resource)', ctx.timeSrv);
|
||||
pm.process().then(function(data) { results = data; });
|
||||
ctx.$httpBackend.flush();
|
||||
ctx.$rootScope.$apply();
|
||||
expect(results.length).to.be(2);
|
||||
expect(results[0].text).to.be("value1");
|
||||
expect(results[1].text).to.be("value2");
|
||||
});
|
||||
it('metrics(metric.*) should generate metric name query', function() {
|
||||
response = {
|
||||
status: "success",
|
||||
|
Loading…
Reference in New Issue
Block a user