diff --git a/CHANGELOG.md b/CHANGELOG.md index c59924a564f..cd9cb6d797e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,53 @@ -# 4.1-beta (unreleased) +# 4.2.0 (unreleased) + +## Enhancements +* **Alerting**: Added Telegram alert notifier [#7098](https://github.com/grafana/grafana/pull/7098), thx [@leonoff](https://github.com/leonoff) +* **Templating**: Make $__interval and $__interval_ms global built in variables that can be used in by any datasource (in panel queries), closes [#7190](https://github.com/grafana/grafana/issues/7190), closes [#6582](https://github.com/grafana/grafana/issues/6582) +* **S3 Image Store**: External s3 image store (used in alert notifications) now support AWS IAM Roles, closes [#6985](https://github.com/grafana/grafana/issues/6985), [#7058](https://github.com/grafana/grafana/issues/7058) thx [@mtanda](https://github.com/mtanda) +* **Optimzation**: Never issue refresh event when Grafana tab is not visible [#7218](https://github.com/grafana/grafana/issues/7218), thx [@mtanda](https://github.com/mtanda) +* **Browser History**: Browser back/forward now works time ranges / zoom, [#7259](https://github.com/grafana/grafana/issues/7259) +* **SingleStat**: Implements diff aggregation method for singlestat [#7234](https://github.com/grafana/grafana/issues/7234), thx [@oliverpool](https://github.com/oliverpool) +* **Dataproxy**: Added setting to enable more verbose logging in dataproxy [#7209](https://github.com/grafana/grafana/pull/7209), thx [@Ricky-N](https://github.com/Ricky-N) +* **Alerting**: Better information about why an alert triggered [#7035](https://github.com/grafana/grafana/issues/7035) +* **LINE**: Add LINE as alerting notification channel [#7301](https://github.com/grafana/grafana/pull/7301), thx [#huydx](https://github.com/huydx) +* **Elasticsearch**: Support for Min Doc Count options in Terms aggregation [#7324](https://github.com/grafana/grafana/pull/7324), thx [#lpic10](https://github.com/lpic10) +* **Elasticsearch**: Term aggregation limit can now be changed in template queries [#7112](https://github.com/grafana/grafana/issues/7112), thx [#FFalcon](https://github.com/FFalcon) + +## Tech + +* **Library Upgrade**: Upgraded angularjs from 1.5.8 to 1.6.1 [#7274](https://github.com/grafana/grafana/issues/7274) + +## Bugfixes +* **Alerting**: Fixes missing support for no_data and execution error when testing alerts [#7149](https://github.com/grafana/grafana/issues/7149) +* **Dashboard**: Avoid duplicate data in dashboard json for panels with alerts [#7256](https://github.com/grafana/grafana/pull/7256) +* **Alertlist**: Only show scrollbar when required [#7269](https://github.com/grafana/grafana/issues/7269) +* **SMTP**: Set LocalName to hostname [#7223](https://github.com/grafana/grafana/issues/7223) +* **Sidemenu**: Disable sign out in sidemenu for AuthProxyEnabled [#7377](https://github.com/grafana/grafana/pull/7377), thx [@solugebefola](https://github.com/solugebefola) + +# 4.1.2 (unreleased) + +### Bugfixes +* **Table**: Fixes broken annotation rendering mode in the table panel [#7268](https://github.com/grafana/grafana/issues/7268) + +# 4.1.1 (2017-01-11) + +### Bugfixes +* **Graph Panel**: Fixed issue with legend height in table mode [#7221](https://github.com/grafana/grafana/issues/7221) + +# 4.1.0 (2017-01-11) + +### Bugfixes +* **Server side PNG rendering**: Fixed issue with y-axis label rotation in phantomjs rendered images [#6924](https://github.com/grafana/grafana/issues/6924) +* **Graph**: Fixed centering of y-axis label [#7099](https://github.com/grafana/grafana/issues/7099) +* **Graph**: Fixed graph legend table mode and always visible scrollbar [#6828](https://github.com/grafana/grafana/issues/6828) +* **Templating**: Fixed template variable value groups/tags feature [#6752](https://github.com/grafana/grafana/issues/6752) +* **Webhook**: Fixed webhook username mismatch [#7195](https://github.com/grafana/grafana/pull/7195), thx [@theisenmark](https://github.com/theisenmark) +* **Influxdb**: Handles time(auto) the same way as time($interval) [#6997](https://github.com/grafana/grafana/issues/6997) + +## Enhancements +* **Elasticsearch**: Added support for all moving average options [#7154](https://github.com/grafana/grafana/pull/7154), thx [@vaibhavinbayarea](https://github.com/vaibhavinbayarea) + +# 4.1-beta1 (2016-12-21) ### Enhancements * **Postgres**: Add support for Certs for Postgres database [#6655](https://github.com/grafana/grafana/issues/6655) @@ -17,6 +66,7 @@ * **Alerting**: Adds OK as no data option. [#6866](https://github.com/grafana/grafana/issues/6866) * **Alert list**: Order alerts based on state. [#6676](https://github.com/grafana/grafana/issues/6676) * **Alerting**: Add api endpoint for pausing all alerts. [#6589](https://github.com/grafana/grafana/issues/6589) +* **Panel**: Added help text for panels. [#4079](https://github.com/grafana/grafana/issues/4079), thx [@utkarshcmu](https://github.com/utkarshcmu) ### Bugfixes * **API**: HTTP API for deleting org returning incorrect message for a non-existing org [#6679](https://github.com/grafana/grafana/issues/6679) diff --git a/Makefile b/Makefile index 40597a33f79..dbb345473ec 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ deps-go: go run build.go setup deps-js: - npm install + yarn install --pure-lockfile deps: deps-go deps-js diff --git a/README.md b/README.md index ab450487c6a..14f5669d846 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[Grafana](http://grafana.org) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana) [![Coverage Status](https://coveralls.io/repos/grafana/grafana/badge.png)](https://coveralls.io/r/grafana/grafana) +[Grafana](http://grafana.org) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana) ================ [Website](http://grafana.org) | [Twitter](https://twitter.com/grafana) | @@ -10,7 +10,7 @@ Grafana is an open source, feature rich metrics dashboard and graph editor for Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB. -![](http://grafana.org/assets/img/start_page_bg.png) +![](http://grafana.org/assets/img/features/dashboard_ex1.png) - [Install instructions](http://docs.grafana.org/installation/) - [What's New in Grafana 2.0](http://docs.grafana.org/guides/whats-new-in-v2/) @@ -18,6 +18,7 @@ Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB. - [What's New in Grafana 2.5](http://docs.grafana.org/guides/whats-new-in-v2-5/) - [What's New in Grafana 3.0](http://docs.grafana.org/guides/whats-new-in-v3/) - [What's New in Grafana 4.0](http://docs.grafana.org/guides/whats-new-in-v4/) +- [What's New in Grafana 4.1](http://docs.grafana.org/guides/whats-new-in-v4-1/) ## Features ### Graphite Target Editor @@ -113,7 +114,8 @@ To build less to css for the frontend you will need a recent version of **node ( npm (v2.5.0) and grunt (v0.4.5). Run the following: ```bash -npm install +npm install -g yarn +yarn install --pure-lockfile npm run build ``` diff --git a/appveyor.yml b/appveyor.yml index 756dbf8fba5..8b4a16d19cb 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -5,13 +5,14 @@ os: Windows Server 2012 R2 clone_folder: c:\gopath\src\github.com\grafana\grafana environment: - nodejs_version: "5" + nodejs_version: "6" GOPATH: c:\gopath install: # install nodejs and npm - ps: Install-Product node $env:nodejs_version - - npm install + - npm install -g yarn + - yarn install --pure-lockfile - npm install -g grunt-cli # install gcc (needed for sqlite3) - choco install -y --limit-output mingw diff --git a/bower.json b/bower.json index c71b0b7dea0..1f7136128aa 100644 --- a/bower.json +++ b/bower.json @@ -15,11 +15,12 @@ "dependencies": { "jquery": "3.1.0", "lodash": "4.15.0", - "angular": "1.5.8", - "angular-route": "1.5.8", - "angular-mocks": "1.5.8", - "angular-sanitize": "1.5.8", + "angular": "1.6.1", + "angular-route": "1.6.1", + "angular-mocks": "1.6.1", + "angular-sanitize": "1.6.1", "angular-native-dragdrop": "1.2.2", - "angular-bindonce": "0.3.3" + "angular-bindonce": "0.3.3", + "clipboard": "^1.5.16" } } diff --git a/build.go b/build.go index faa014d7619..f30c5ff88e8 100644 --- a/build.go +++ b/build.go @@ -37,6 +37,7 @@ var ( race bool phjsToRelease string workingDir string + includeBuildNumber bool = true binaries []string = []string{"grafana-server", "grafana-cli"} ) @@ -47,9 +48,6 @@ func main() { log.SetFlags(0) ensureGoPath() - readVersionFromPackageJson() - - log.Printf("Version: %s, Linux Version: %s, Package Iteration: %s\n", version, linuxPackageVersion, linuxPackageIteration) flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH") flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS") @@ -59,8 +57,13 @@ func main() { flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH") flag.StringVar(&phjsToRelease, "phjs", "", "PhantomJS binary") flag.BoolVar(&race, "race", race, "Use race detector") + flag.BoolVar(&includeBuildNumber, "includeBuildNumber", includeBuildNumber, "IncludeBuildNumber in package name") flag.Parse() + readVersionFromPackageJson() + + log.Printf("Version: %s, Linux Version: %s, Package Iteration: %s\n", version, linuxPackageVersion, linuxPackageIteration) + if flag.NArg() == 0 { log.Println("Usage: go run build.go build") return @@ -73,9 +76,9 @@ func main() { case "setup": setup() - case "build-cli": - clean() - build("grafana-cli", "./pkg/cmd/grafana-cli", []string{}) + case "build-cli": + clean() + build("grafana-cli", "./pkg/cmd/grafana-cli", []string{}) case "build": clean() @@ -90,24 +93,20 @@ func main() { case "package": grunt(gruntBuildArg("release")...) createLinuxPackages() - sha1FilesInDist() case "pkg-rpm": grunt(gruntBuildArg("release")...) createRpmPackages() - sha1FilesInDist() case "pkg-deb": grunt(gruntBuildArg("release")...) createDebPackages() - sha1FilesInDist() - case "sha1-dist": - sha1FilesInDist() + case "sha1-dist": + sha1FilesInDist() case "latest": makeLatestDistCopies() - sha1FilesInDist() case "clean": clean() @@ -157,7 +156,9 @@ func readVersionFromPackageJson() { } // add timestamp to iteration - linuxPackageIteration = fmt.Sprintf("%d%s", time.Now().Unix(), linuxPackageIteration) + if includeBuildNumber { + linuxPackageIteration = fmt.Sprintf("%d%s", time.Now().Unix(), linuxPackageIteration) + } } type linuxPackageOptions struct { @@ -167,7 +168,6 @@ type linuxPackageOptions struct { serverBinPath string cliBinPath string configDir string - configFilePath string ldapFilePath string etcDefaultPath string etcDefaultFilePath string @@ -188,8 +188,6 @@ func createDebPackages() { homeDir: "/usr/share/grafana", binPath: "/usr/sbin", configDir: "/etc/grafana", - configFilePath: "/etc/grafana/grafana.ini", - ldapFilePath: "/etc/grafana/ldap.toml", etcDefaultPath: "/etc/default", etcDefaultFilePath: "/etc/default/grafana-server", initdScriptFilePath: "/etc/init.d/grafana-server", @@ -210,8 +208,6 @@ func createRpmPackages() { homeDir: "/usr/share/grafana", binPath: "/usr/sbin", configDir: "/etc/grafana", - configFilePath: "/etc/grafana/grafana.ini", - ldapFilePath: "/etc/grafana/ldap.toml", etcDefaultPath: "/etc/sysconfig", etcDefaultFilePath: "/etc/sysconfig/grafana-server", initdScriptFilePath: "/etc/init.d/grafana-server", @@ -222,7 +218,7 @@ func createRpmPackages() { defaultFileSrc: "packaging/rpm/sysconfig/grafana-server", systemdFileSrc: "packaging/rpm/systemd/grafana-server.service", - depends: []string{"initscripts", "fontconfig"}, + depends: []string{"/sbin/service", "fontconfig"}, }) } @@ -256,10 +252,6 @@ func createPackage(options linuxPackageOptions) { runPrint("cp", "-a", filepath.Join(workingDir, "tmp")+"/.", filepath.Join(packageRoot, options.homeDir)) // remove bin path runPrint("rm", "-rf", filepath.Join(packageRoot, options.homeDir, "bin")) - // copy sample ini file to /etc/grafana - runPrint("cp", "conf/sample.ini", filepath.Join(packageRoot, options.configFilePath)) - // copy sample ldap toml config file to /etc/grafana/ldap.toml - runPrint("cp", "conf/ldap.toml", filepath.Join(packageRoot, options.ldapFilePath)) args := []string{ "-s", "dir", @@ -269,8 +261,6 @@ func createPackage(options linuxPackageOptions) { "--url", "http://grafana.org", "--license", "\"Apache 2.0\"", "--maintainer", "contact@grafana.org", - "--config-files", options.configFilePath, - "--config-files", options.ldapFilePath, "--config-files", options.initdScriptFilePath, "--config-files", options.etcDefaultFilePath, "--config-files", options.systemdServiceFilePath, @@ -334,7 +324,12 @@ func grunt(params ...string) { } func gruntBuildArg(task string) []string { - args := []string{task, fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration)} + var args []string + if includeBuildNumber { + args = append(args, fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration)) + } else { + args = append(args, fmt.Sprintf("--pkgVer=%v", linuxPackageVersion)) + } if pkgArch != "" { args = append(args, fmt.Sprintf("--arch=%v", pkgArch)) } @@ -429,14 +424,10 @@ func setBuildEnv() { } func getGitSha() string { - v, err := runError("git", "describe", "--always", "--dirty") + v, err := runError("git", "rev-parse", "--short", "HEAD") if err != nil { return "unknown-dev" } - v = versionRe.ReplaceAllFunc(v, func(s []byte) []byte { - s[0] = '+' - return s - }) return string(v) } @@ -516,8 +507,15 @@ func md5File(file string) error { func sha1FilesInDist() { filepath.Walk("./dist", func(path string, f os.FileInfo, err error) error { + if path == "./dist" { + return nil + } + if strings.Contains(path, ".sha1") == false { - sha1File(path) + err := sha1File(path) + if err != nil { + log.Printf("Failed to create sha file. error: %v\n", err) + } } return nil }) diff --git a/circle.yml b/circle.yml index ffc3450ad6b..6258b9fbfcc 100644 --- a/circle.yml +++ b/circle.yml @@ -1,18 +1,26 @@ machine: node: - version: 5.11.1 + version: 6.9.2 + python: + version: 2.7.3 + services: + - docker environment: GOPATH: "/home/ubuntu/.go_workspace" ORG_PATH: "github.com/grafana" REPO_PATH: "${ORG_PATH}/grafana" GODIST: "go1.7.4.linux-amd64.tar.gz" post: - - mkdir -p download + - mkdir -p ~/download + - mkdir -p ~/docker - test -e download/$GODIST || curl -o download/$GODIST https://storage.googleapis.com/golang/$GODIST - sudo rm -rf /usr/local/go - sudo tar -C /usr/local -xzf download/$GODIST dependencies: + cache_directories: + - "~/docker" + - "~/download" override: - rm -rf ${GOPATH}/src/${REPO_PATH} - mkdir -p ${GOPATH}/src/${ORG_PATH} @@ -23,9 +31,26 @@ test: - bash scripts/circle-test.sh deployment: - master: - branch: master - owner: grafana + gh_branch: + branch: new_master commands: - - ./scripts/trigger_grafana_packer.sh ${TRIGGER_GRAFANA_PACKER_CIRCLECI_TOKEN} - - ./scripts/trigger_windows_build.sh ${APPVEYOR_TOKEN} + - pip install awscli + - sudo apt-get update; sudo apt-get install rpm; sudo apt-get install expect + - ./scripts/build/build_container.sh + - ./scripts/build/deploy.sh + - ./scripts/build/sign_packages.sh + - go run build.go sha1-dist + - aws s3 sync ./dist s3://$BUCKET_NAME/master + #- ./scripts/trigger_grafana_docker_build.sh ${TRIGGER_GRAFANA_DOCKER_CIRCLECI_TOKEN} + gh_tag: + tag: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/ + commands: + - pip install awscli + - sudo apt-get update; sudo apt-get install rpm; sudo apt-get install expect + - ./scripts/build/build_container.sh + - ./scripts/build/deploy.sh + - ./scripts/build/sign_packages.sh + - go run build.go sha1-dist + - aws s3 sync ./dist s3://$BUCKET_NAME/release + #- ./scripts/trigger_grafana_docker_build.sh ${TRIGGER_GRAFANA_DOCKER_CIRCLECI_TOKEN} + diff --git a/conf/defaults.ini b/conf/defaults.ini index 81d6c8d8050..358847724ab 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -113,6 +113,12 @@ cookie_secure = false session_life_time = 86400 gc_interval_time = 86400 +#################################### Data proxy ########################### +[dataproxy] + +# This enables data proxy logging, default is false +logging = false + #################################### Analytics ########################### [analytics] # Server reporting, sends usage counters to stats.grafana.org every 24 hours. @@ -279,6 +285,7 @@ allow_sign_up = true enabled = false host = localhost:25 user = +# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" password = cert_file = key_file = @@ -395,7 +402,9 @@ global_session = -1 #################################### Alerting ############################ [alerting] -# Makes it possible to turn off alert rule execution. +# Disable alerting engine & UI features +enabled = true +# Makes it possible to turn off alert rule execution but alerting UI is visible execute_alerts = true #################################### Internal Grafana Metrics ############ diff --git a/conf/ldap.toml b/conf/ldap.toml index 28ed8f46bf1..305929b80d7 100644 --- a/conf/ldap.toml +++ b/conf/ldap.toml @@ -19,6 +19,7 @@ ssl_skip_verify = false # Search user bind dn bind_dn = "cn=admin,dc=grafana,dc=org" # Search user bind password +# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" bind_password = 'grafana' # User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)" diff --git a/conf/sample.ini b/conf/sample.ini index 6a8aa623f85..ce9344e1d4f 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -104,6 +104,13 @@ # Session life time, default is 86400 ;session_life_time = 86400 +#################################### Data proxy ########################### +[dataproxy] + +# This enables data proxy logging, default is false +;logging = false + + #################################### Analytics #################################### [analytics] # Server reporting, sends usage counters to stats.grafana.org every 24 hours. @@ -263,6 +270,7 @@ ;enabled = false ;host = localhost:25 ;user = +# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" ;password = ;cert_file = ;key_file = @@ -342,9 +350,11 @@ ;enabled = false ;path = /var/lib/grafana/dashboards -#################################### Alerting ###################################### +#################################### Alerting ############################ [alerting] -# Makes it possible to turn off alert rule execution. +# Disable alerting engine & UI features +;enabled = true +# Makes it possible to turn off alert rule execution but alerting UI is visible ;execute_alerts = true #################################### Internal Grafana Metrics ########################## diff --git a/docs/sources/administration/cli.md b/docs/sources/administration/cli.md new file mode 100644 index 00000000000..99446fb5f1b --- /dev/null +++ b/docs/sources/administration/cli.md @@ -0,0 +1,30 @@ ++++ +title = "Grafana CLI" +description = "Guide to using grafana-cli" +keywords = ["grafana", "cli", "grafana-cli", "command line interface"] +type = "docs" +[menu.docs] +parent = "admin" +weight = 8 ++++ + +# Grafana CLI + +Grafana cli is a small executable that is bundled with grafana server and is suppose to be executed on the same machine as grafana runs. + +## Plugins + +The CLI helps you install, upgrade and manage your plugins on the same machine it CLI is running. You can find more information about how to install and manage your plugins at the [plugin page] ({{< relref "/installation.md" >}}) + +## Admin + +> This feature is only available in grafana 4.1 and above. + +To show all admin commands: +`grafana-cli admin` + +### Reset admin password + +You can reset the password for the admin user using the CLI. + +`grafana-cli admin reset-admin-password ...` diff --git a/docs/sources/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md similarity index 100% rename from docs/sources/datasources/cloudwatch.md rename to docs/sources/features/datasources/cloudwatch.md diff --git a/docs/sources/datasources/elasticsearch.md b/docs/sources/features/datasources/elasticsearch.md similarity index 100% rename from docs/sources/datasources/elasticsearch.md rename to docs/sources/features/datasources/elasticsearch.md diff --git a/docs/sources/datasources/graphite.md b/docs/sources/features/datasources/graphite.md similarity index 100% rename from docs/sources/datasources/graphite.md rename to docs/sources/features/datasources/graphite.md diff --git a/docs/sources/datasources/index.md b/docs/sources/features/datasources/index.md similarity index 100% rename from docs/sources/datasources/index.md rename to docs/sources/features/datasources/index.md diff --git a/docs/sources/datasources/influxdb.md b/docs/sources/features/datasources/influxdb.md similarity index 100% rename from docs/sources/datasources/influxdb.md rename to docs/sources/features/datasources/influxdb.md diff --git a/docs/sources/datasources/kairosdb.md b/docs/sources/features/datasources/kairosdb.md similarity index 100% rename from docs/sources/datasources/kairosdb.md rename to docs/sources/features/datasources/kairosdb.md diff --git a/docs/sources/datasources/opentsdb.md b/docs/sources/features/datasources/opentsdb.md similarity index 100% rename from docs/sources/datasources/opentsdb.md rename to docs/sources/features/datasources/opentsdb.md diff --git a/docs/sources/datasources/plugin_api.md b/docs/sources/features/datasources/plugin_api.md similarity index 100% rename from docs/sources/datasources/plugin_api.md rename to docs/sources/features/datasources/plugin_api.md diff --git a/docs/sources/datasources/prometheus.md b/docs/sources/features/datasources/prometheus.md similarity index 91% rename from docs/sources/datasources/prometheus.md rename to docs/sources/features/datasources/prometheus.md index fd14d0539e7..2a35bb615cb 100644 --- a/docs/sources/datasources/prometheus.md +++ b/docs/sources/features/datasources/prometheus.md @@ -72,4 +72,9 @@ label_values(hostname) You can also use raw queries & regular expressions to extract anything you might need. +### Using templated variables in queries + +When the `Include All` option or `Multi-Value` option is enabled, Grafana converts the labels from plain text to a regex compatible string. +Which means you have to use `=~` instead of `=` in your Prometheus queries. For example `ALERTS{instance=~$instance}` instead of `ALERTS{instance=$instance}`. + ![](/img/v2/prometheus_templating.png) diff --git a/docs/sources/features/datasources/testdata.md b/docs/sources/features/datasources/testdata.md new file mode 100644 index 00000000000..02e99f7dd8a --- /dev/null +++ b/docs/sources/features/datasources/testdata.md @@ -0,0 +1,54 @@ ++++ +title = "Grafana TestData" +keywords = ["grafana", "dashboard", "documentation", "panels", "testdata"] +type = "docs" +[menu.docs] +name = "Grafana TestData" +parent = "datasources" +weight = 2 ++++ + + +# Grafana TestData + + > NOTE: This plugin is disable by default. + +The purpose of this data sources is to make it easier to create fake data for any panel. +Using `Grafana TestData` you can build your own time series and have any panel render it. +This make is much easier to verify functionally since the data can be shared very + +## Enable + +`Grafana TestData` is not enabled by default. To enable it you have to go to `/plugins/testdata/edit` and click the enable button to enable it for each server. + +## Create mock data. + +Once `Grafana TestData` is enabled you use it as a datasource in the metric panel. + +![](/img/docs/v41/test_data_add.png) + +## Scenarios + +You can now choose different scenario that you want rendered in the drop down menu. If you have scenarios that you think should be added, please add them to `` and submit a pull request. + +## CSV + +The comma separated values scenario is the most powerful one since it lets you create any kind of graph you like. +Once you provided the numbers `Grafana TestData` will distribute them evenly based on the time range of your query. + +![](/img/docs/v41/test_data_csv_example.png) + + +## Dashboards + +`Grafana TestData` also contains some dashboards with example. `/plugins/testdata/edit` + +### Commit updates to the dashboards + +If you want to submit a change to one of the current dashboards bundled with `Grafana TestData` you have to update the revision property. +Otherwise the dashboard will not be updated automatically for other Grafana users. + +## Using test data in issues + +If you post an issue on github regarding time series data or rendering of time series data we strongly advice you to use this data source to replicate the data. +That makes it much easier for the developers to replicate and solve the issue you have. diff --git a/docs/sources/reference/dashlist.md b/docs/sources/features/panels/dashlist.md similarity index 100% rename from docs/sources/reference/dashlist.md rename to docs/sources/features/panels/dashlist.md diff --git a/docs/sources/reference/graph.md b/docs/sources/features/panels/graph.md similarity index 100% rename from docs/sources/reference/graph.md rename to docs/sources/features/panels/graph.md diff --git a/docs/sources/reference/singlestat.md b/docs/sources/features/panels/singlestat.md similarity index 97% rename from docs/sources/reference/singlestat.md rename to docs/sources/features/panels/singlestat.md index ed0a956b7eb..9f2edb7df05 100644 --- a/docs/sources/reference/singlestat.md +++ b/docs/sources/features/panels/singlestat.md @@ -26,11 +26,12 @@ The singlestat panel has a normal query editor to allow you define your exact me 3. `Values`: The Value fields let you set the function (min, max, average, current, total, first, delta, range) that your entire query is reduced into a single value with. You can also set the font size of the Value field and font-size (as a %) of the metric query that the Panel is configured with. This reduces the entire query into a single summary value that is displayed. * `min` - The smallest value in the series * `max` - The largest value in the series - * `average` - The average of all the non-null values in the series + * `avg` - The average of all the non-null values in the series * `current` - The last value in the series. If the series ends on null the previous value will be used. * `total` - The sum of all the non-null values in the series * `first` - The first value in the series * `delta` - The total incremental increase (of a counter) in the series. An attempt is made to account for counter resets, but this will only be accurate for single instance metrics. Used to show total counter increase in time series. + * `diff` - The difference betwen 'current' (last value) and 'first'. * `range` - The difference between 'min' and 'max'. Useful the show the range of change for a gauge. 4. `Postfixes`: The Postfix fields let you define a custom label and font-size (as a %) to appear *after* the value 5. `Units`: Units are appended to the the Singlestat within the panel, and will respect the color and threshold settings for the value. diff --git a/docs/sources/reference/table_panel.md b/docs/sources/features/panels/table_panel.md similarity index 100% rename from docs/sources/reference/table_panel.md rename to docs/sources/features/panels/table_panel.md diff --git a/docs/sources/guides/whats-new-in-v3-1.md b/docs/sources/guides/whats-new-in-v3-1.md index 236e77ea266..3f1c25eb937 100644 --- a/docs/sources/guides/whats-new-in-v3-1.md +++ b/docs/sources/guides/whats-new-in-v3-1.md @@ -7,7 +7,7 @@ type = "docs" name = "Version 3.1" identifier = "v3.1" parent = "whatsnew" -weight = 1 +weight = 5 +++ # What's New in Grafana v3.1 diff --git a/docs/sources/guides/whats-new-in-v3.md b/docs/sources/guides/whats-new-in-v3.md index 32912754fe9..fd4d1e40a18 100644 --- a/docs/sources/guides/whats-new-in-v3.md +++ b/docs/sources/guides/whats-new-in-v3.md @@ -7,7 +7,7 @@ type = "docs" name = "Version 3.0" identifier = "v3.0" parent = "whatsnew" -weight = 2 +weight = 6 +++ # What's New in Grafana v3.0 diff --git a/docs/sources/guides/whats-new-in-v4-1.md b/docs/sources/guides/whats-new-in-v4-1.md new file mode 100644 index 00000000000..59e6236af40 --- /dev/null +++ b/docs/sources/guides/whats-new-in-v4-1.md @@ -0,0 +1,70 @@ ++++ +title = "What's New in Grafana v4.1" +description = "Feature & improvement highlights for Grafana v4.1" +keywords = ["grafana", "new", "documentation", "4.1.0"] +type = "docs" +[menu.docs] +name = "Version 4.1" +identifier = "v4.1" +parent = "whatsnew" +weight = -1 ++++ + + +## Whats new in Grafana v4.1 +- **Graph**: Support for shared tooltip on all graphs as you hover over one graph. [#1578](https://github.com/grafana/grafana/pull/1578), [#6274](https://github.com/grafana/grafana/pull/6274) +- **Victorops**: Add VictorOps notification integration [#6411](https://github.com/grafana/grafana/issues/6411), thx [@ichekrygin](https://github.com/ichekrygin) +- **Opsgenie**: Add OpsGenie notification integratiion [#6687](https://github.com/grafana/grafana/issues/6687), thx [@kylemcc](https://github.com/kylemcc) +- **Cloudwatch**: Make it possible to specify access and secret key on the data source config page [#6697](https://github.com/grafana/grafana/issues/6697) +- **Elasticsearch**: Added support for Elasticsearch 5.x [#5740](https://github.com/grafana/grafana/issues/5740), thx [@lpic10](https://github.com/lpic10) +- **Panel**: Added help text for panels. [#4079](https://github.com/grafana/grafana/issues/4079), thx [@utkarshcmu](https://github.com/utkarshcmu) +- [Full changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) + +### Shared tooltip + +{{< imgbox max-width="60%" img="/img/docs/v41/shared_tooltip.gif" caption="Shared tooltip" >}} + +Showing the tooltip on all panels at the same time has been a long standing request in Grafana and we are really happy to finally be able to release it. +You can enable/disable the shared tooltip from the dashboard settings menu or cycle between default, shared tooltip and shared crosshair by pressing `CTRL + O` or `CMD + O`. + +
+ +### Help text for panel + +{{< imgbox max-width="60%" img="/img/docs/v41/helptext_for_panel_settings.png" caption="Hovering help text" >}} + +You can set a help text in the general tab on any panel. The help text is using Markdown to enable better formating and linking to other sites that can provide more information. + +
+ +{{< imgbox max-width="60%" img="/img/docs/v41/helptext_hover.png" caption="Hovering help text" >}} + +Panels with a help text available have a little indicator in the top left corner. You can show the help text by hovering the icon. +
+ + +### Easier Cloudwatch configuration + +{{< imgbox max-width="60%" img="/img/docs/v41/cloudwatch_settings.png" caption="Cloudwatch configuration" >}} + +In Grafana 4.1.0 you can configure your Cloudwatch data source with `access key` and `secret key` directly in the data source configuration page. +This enables people to use the Cloudwatch data source without having access to the filesystem where Grafana is running. + +Once the `access key` and `secret key` have been saved the user will no longer be able to view them. +
+ +## Upgrade & Breaking changes + +Elasticsearch 1.x is no longer supported. Please upgrade to Elasticsearch 2.x or 5.x. Otherwise Grafana 4.1.0 contains no breaking changes. + +## Changelog + +Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list +of new features, changes, and bug fixes. + +## Download + +Head to [v4.1 download page](/download/4_1_0/) for download links & instructions. + +## Thanks +A big thanks to all the Grafana users who contribute by submitting PRs, bug reports & feedback! diff --git a/docs/sources/guides/whats-new-in-v4.md b/docs/sources/guides/whats-new-in-v4.md index 71a5c0251da..cfd454cdc23 100644 --- a/docs/sources/guides/whats-new-in-v4.md +++ b/docs/sources/guides/whats-new-in-v4.md @@ -4,10 +4,10 @@ description = "Feature & improvement highlights for Grafana v4.0" keywords = ["grafana", "new", "documentation", "4.0"] type = "docs" [menu.docs] -name = "Version 4.0 (Latest)" +name = "Version 4.0" identifier = "v4.0" parent = "whatsnew" -weight = -1 +weight = 4 +++ # What's New in Grafana v4.0 diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index 2d15b19454c..8aa00d08618 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -143,6 +143,7 @@ with Grafana admin permission. "protocol":"http", "root_url":"%(protocol)s://%(domain)s:%(http_port)s/", "router_logging":"true", + "data_proxy_logging":"true", "static_root_path":"public" }, "session":{ @@ -275,3 +276,20 @@ Change password for specific user Content-Type: application/json {message: "User deleted"} + +## Pause all alerts + +`DELETE /api/admin/pause-all-alerts` + +**Example Request**: + + DELETE /api/admin/pause-all-alerts HTTP/1.1 + Accept: application/json + Content-Type: application/json + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + + {state: "new state", message: "alerts pause/un paused", "alertsAffected": 100} diff --git a/docs/sources/http_api/alerting.md b/docs/sources/http_api/alerting.md new file mode 100644 index 00000000000..b58a177fecd --- /dev/null +++ b/docs/sources/http_api/alerting.md @@ -0,0 +1,216 @@ ++++ +title = "Alerting HTTP API " +description = "Grafana Alerting HTTP API" +keywords = ["grafana", "http", "documentation", "api", "alerting"] +aliases = ["/http_api/alerting/"] +type = "docs" +[menu.docs] +name = "Alerting" +parent = "http_api" ++++ + + +# Alerting API + +You can use the Alerting API to get information about alerts and their states but this API cannot be used to modify the alert. +To create new alerts or modify them you need to update the dashboard json that contains the alerts. + +This API can also be used to create, update and delete alert notifications. + +## Get alerts + +`GET /api/alerts/` + +**Example Request**: + + GET /api/alerts HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + [ + { + "id": 1, + "dashboardId": 1, + "panelId": 1, + "name": "fire place sensor", + "message": "Someone is trying to break in through the fire place", + "state": "alerting", + "newStateDate": "2016-12-25", + "executionError": "", + "dashboardUri": "http://grafana.com/dashboard/db/sensors" + } + ] + +## Get one alert + +`GET /api/alerts/:id` + +**Example Request**: + + GET /api/alerts/1 HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + { + "id": 1, + "dashboardId": 1, + "panelId": 1, + "name": "fire place sensor", + "message": "Someone is trying to break in through the fire place", + "state": "alerting", + "newStateDate": "2016-12-25", + "executionError": "", + "dashboardUri": "http://grafana.com/dashboard/db/sensors" + } + + +## Pause alert + +`POST /api/alerts/:id/pause` + +**Example Request**: + + POST /api/alerts/1/pause HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + + { + "alertId": 1, + "paused: true + } + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + { + "alertId": 1, + "state": "Paused", + "message": "alert paused" + } + +## Get alert notifications + +`GET /api/alert-notifications` + +**Example Request**: + + GET /api/alert-notifications HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + + { + "id": 1, + "name": "Team A", + "type": "email", + "isDefault": true, + "created": "2017-01-01 12:45", + "updated": "2017-01-01 12:45" + } + +## Create alert notification + +`POST /api/alerts-notifications` + +**Example Request**: + + POST /api/alerts-notifications HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + + { + "name": "new alert notification", //Required + "type": "email", //Required + "isDefault": false, + "settings": { + "addresses": "carl@grafana.com;dev@grafana.com" + } + } + + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + { + "id": 1, + "name": "new alert notification", + "type": "email", + "isDefault": false, + "settings": { addresses: "carl@grafana.com;dev@grafana.com"} } + "created": "2017-01-01 12:34", + "updated": "2017-01-01 12:34" + } + +## Update alert notification + +`PUT /api/alerts-notifications/1` + +**Example Request**: + + PUT /api/alerts-notifications/1 HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + + { + "id": 1, + "name": "new alert notification", //Required + "type": "email", //Required + "isDefault": false, + "settings": { + "addresses: "carl@grafana.com;dev@grafana.com" + } + } + + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + { + "id": 1, + "name": "new alert notification", + "type": "email", + "isDefault": false, + "settings": { addresses: "carl@grafana.com;dev@grafana.com"} } + "created": "2017-01-01 12:34", + "updated": "2017-01-01 12:34" + } + +## Delete alert notification + +`DELETE /api/alerts-notifications/:notificationId` + +**Example Request**: + + DELETE /api/alerts-notifications/1 HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + { + "message": "Notification deleted" + } diff --git a/docs/sources/http_api/dashboard.md b/docs/sources/http_api/dashboard.md index 5e9891e0a89..5fed69b4a93 100644 --- a/docs/sources/http_api/dashboard.md +++ b/docs/sources/http_api/dashboard.md @@ -200,7 +200,7 @@ Get all tags of dashboards **Example Request**: - GET /api/dashboards/home HTTP/1.1 + GET /api/dashboards/tags HTTP/1.1 Accept: application/json Content-Type: application/json Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk diff --git a/docs/sources/http_api/data_source.md b/docs/sources/http_api/data_source.md index 9fb1acb4b68..dd8cbc9aa74 100644 --- a/docs/sources/http_api/data_source.md +++ b/docs/sources/http_api/data_source.md @@ -158,7 +158,7 @@ parent = "http_api" HTTP/1.1 200 Content-Type: application/json - {"id":1,"message":"Datasource added"} + {"id":1,"message":"Datasource added", "name": "test_datasource"} ## Update an existing data source @@ -193,7 +193,7 @@ parent = "http_api" HTTP/1.1 200 Content-Type: application/json - {"message":"Datasource updated"} + {"message":"Datasource updated", "id": 1, "name": "test_datasource"} ## Delete an existing data source diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md index 734fbb4f934..2309b56fa2a 100644 --- a/docs/sources/http_api/user.md +++ b/docs/sources/http_api/user.md @@ -69,6 +69,40 @@ parent = "http_api" "isGrafanaAdmin": true } +## Get single user by Username(login) or Email + + `GET /api/users/lookup` + + **Parameter:** `loginOrEmail` + + **Example Request using the email as option**: + + GET /api/users/lookup?loginOrEmail=user@mygraf.com HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + + **Example Request using the username as option**: + GET /api/users/lookup?loginOrEmail=admin HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + + **Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + + { + "email": "user@mygraf.com" + "name": "admin", + "login": "admin", + "theme": "light", + "orgId": 1, + "isGrafanaAdmin": true + } + + ## User Update `PUT /api/users/:id` diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index e5d86e934c8..e222f8b5f4f 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -144,6 +144,10 @@ Grafana needs a database to store users and dashboards (and other things). By default it is configured to use `sqlite3` which is an embedded database (included in the main Grafana binary). +### url +Use either URL or or the other fields below to configure the database +Example: `mysql://user:secret@host:port/database` + ### type Either `mysql`, `postgres` or `sqlite3`, it's your choice. @@ -244,7 +248,10 @@ organization to be created for that new user. The role new users will be assigned for the main organization (if the above setting is set to true). Defaults to `Viewer`, other valid -options are `Admin` and `Editor` and `Read-Only Editor`. +options are `Admin` and `Editor` and `Read Only Editor`. e.g. : + +`auto_assign_org_role = Read Only Editor` +
@@ -611,6 +618,11 @@ basic auth password ## [alerting] +### enabled +Defaults to true. Set to false to disable alerting engine and hide Alerting from UI. + +### execute_alerts + ### execute_alerts = true Makes it possible to turn off alert rule execution. diff --git a/docs/sources/installation/debian.md b/docs/sources/installation/debian.md index 5384593b302..088f7d14461 100644 --- a/docs/sources/installation/debian.md +++ b/docs/sources/installation/debian.md @@ -15,14 +15,14 @@ weight = 1 Description | Download ------------ | ------------- -Stable for Debian-based Linux | [4.0.2 (x86-64 deb)](https://grafanarel.s3.amazonaws.com/builds/grafana_4.0.2-1481203731_amd64.deb) +Stable for Debian-based Linux | [4.1.1 (x86-64 deb)](https://grafanarel.s3.amazonaws.com/builds/grafana_4.1.1-1484211277_amd64.deb) ## Install Stable ``` -$ wget https://grafanarel.s3.amazonaws.com/builds/grafana_4.0.2-1481203731_amd64.deb +$ wget https://grafanarel.s3.amazonaws.com/builds/grafana_4.1.1-1484211277_amd64.deb $ sudo apt-get install -y adduser libfontconfig -$ sudo dpkg -i grafana_4.0.2-1481203731_amd64.deb +$ sudo dpkg -i grafana_4.1.1-1484211277_amd64.deb ``` ## APT Repository diff --git a/docs/sources/installation/ldap.md b/docs/sources/installation/ldap.md index 2a68ee2172e..d8538182b68 100644 --- a/docs/sources/installation/ldap.md +++ b/docs/sources/installation/ldap.md @@ -43,6 +43,7 @@ ssl_skip_verify = false # Search user bind dn bind_dn = "cn=admin,dc=grafana,dc=org" # Search user bind password +# If the password contains # or ; you have to wrap it with trippel quotes. Ex """#password;""" bind_password = 'grafana' # User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)" diff --git a/docs/sources/installation/rpm.md b/docs/sources/installation/rpm.md index cd99439066f..8be68232011 100644 --- a/docs/sources/installation/rpm.md +++ b/docs/sources/installation/rpm.md @@ -15,24 +15,24 @@ weight = 2 Description | Download ------------ | ------------- -Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.0.2 (x86-64 rpm)](https://grafanarel.s3.amazonaws.com/builds/grafana-4.0.2-1481203731.x86_64.rpm) +Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [4.1.1 (x86-64 rpm)](https://grafanarel.s3.amazonaws.com/builds/grafana-4.1.1-1484211277.x86_64.rpm) ## Install Stable You can install Grafana using Yum directly. - $ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-4.0.2-1481203731.x86_64.rpm + $ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-4.1.1-1484211277.x86_64.rpm Or install manually using `rpm`. #### On CentOS / Fedora / Redhat: $ sudo yum install initscripts fontconfig - $ sudo rpm -Uvh grafana-4.0.2-1481203731.x86_64.rpm + $ sudo rpm -Uvh grafana-4.1.1-1484211277.x86_64.rpm #### On OpenSuse: - $ sudo rpm -i --nodeps grafana-4.0.2-1481203731.x86_64.rpm + $ sudo rpm -i --nodeps grafana-4.1.1-1484211277.x86_64.rpm ## Install via YUM Repository diff --git a/docs/sources/installation/windows.md b/docs/sources/installation/windows.md index 5217181f1ee..0036a50694d 100644 --- a/docs/sources/installation/windows.md +++ b/docs/sources/installation/windows.md @@ -13,7 +13,7 @@ weight = 3 Description | Download ------------ | ------------- -Latest stable package for Windows | [grafana.4.0.2.windows-x64.zip](https://grafanarel.s3.amazonaws.com/builds/grafana-4.0.2.windows-x64.zip) +Latest stable package for Windows | [grafana.4.1.1.windows-x64.zip](https://grafanarel.s3.amazonaws.com/builds/grafana-4.1.1.windows-x64.zip) ## Configure diff --git a/docs/sources/plugins/datasources.md b/docs/sources/plugins/datasources.md index 9f04d11cdfc..332629aa5d8 100644 --- a/docs/sources/plugins/datasources.md +++ b/docs/sources/plugins/datasources.md @@ -37,7 +37,7 @@ The Datasource should contain the following functions. ``` query(options) //used by panels to get data testDatasource() //used by datasource configuration page to make sure the connection is working -annotationsQuery(options) // used by dashboards to get annotations +annotationQuery(options) // used by dashboards to get annotations metricFindQuery(options) // used by query editor to get metric suggestions. ``` @@ -119,7 +119,7 @@ An array of ### Annotation Query -Request object passed to datasource.annotationsQuery function +Request object passed to datasource.annotationQuery function ```json { "range": { "from": "2016-03-04T04:07:55.144Z", "to": "2016-03-04T07:07:55.144Z" }, @@ -172,4 +172,4 @@ Requires a static template or templateUrl variable which will be rendered as the A javascript class that will be instantiated and treated as an Angular controller when the user choose this type of datasource in the templating menu in the dashboard. -Requires a static template or templateUrl variable which will be rendered as the view for this controller. The fields that are bound to this controller is then sent to the Database objects annotationsQuery function. +Requires a static template or templateUrl variable which will be rendered as the view for this controller. The fields that are bound to this controller is then sent to the Database objects annotationQuery function. diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md index df07b4bdc62..195c71d99a8 100644 --- a/docs/sources/project/building_from_source.md +++ b/docs/sources/project/building_from_source.md @@ -23,6 +23,8 @@ export GOPATH=`pwd` go get github.com/grafana/grafana ``` +You may see an error such as: `package github.com/grafana/grafana: no buildable Go source files`. This is just a warning, and you can proceed with the directions. + ## Building the backend ``` cd $GOPATH/src/github.com/grafana/grafana @@ -40,7 +42,8 @@ To build less to css for the frontend you will need a recent version of node (v0 npm (v2.5.0) and grunt (v0.4.5). Run the following: ``` -npm install +npm install -g yarn +yarn install --pure-lockfile npm install -g grunt-cli grunt ``` diff --git a/latest.json b/latest.json index 78fd48769e8..75f20d75a69 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "4.0.2", - "testing": "4.0.2" + "stable": "4.1.1", + "testing": "4.1.1" } diff --git a/package.json b/package.json index 8f5585d4c81..dc3970bfe69 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "company": "Coding Instinct AB" }, "name": "grafana", - "version": "4.1.0-pre1", + "version": "4.2.0-pre1", "repository": { "type": "git", "url": "http://github.com/grafana/grafana.git" @@ -29,7 +29,6 @@ "grunt-contrib-watch": "^1.0.0", "grunt-exec": "^1.0.1", "grunt-filerev": "^2.3.1", - "grunt-git-describe": "~2.4.2", "grunt-karma": "~2.0.0", "grunt-ng-annotate": "^3.0.0", "grunt-notify": "^0.4.5", @@ -42,13 +41,12 @@ "karma": "1.3.0", "karma-chrome-launcher": "~2.0.0", "karma-coverage": "1.1.1", - "karma-coveralls": "1.1.2", "karma-expect": "~1.1.3", "karma-mocha": "~1.3.0", "karma-phantomjs-launcher": "1.0.2", "load-grunt-tasks": "3.5.2", "mocha": "3.2.0", - "phantomjs-prebuilt": "^2.1.13", + "phantomjs-prebuilt": "^2.1.14", "reflect-metadata": "0.1.8", "rxjs": "^5.0.0-rc.5", "sass-lint": "^1.10.2", @@ -60,9 +58,8 @@ "npm": "2.14.x" }, "scripts": { - "build": "grunt", - "test": "grunt test", - "coveralls": "grunt karma:coveralls && rm -rf ./coverage" + "build": "./node_modules/grunt-cli/bin/grunt", + "test": "./node_modules/grunt-cli/bin/grunt test" }, "license": "Apache-2.0", "dependencies": { @@ -78,7 +75,7 @@ "sinon": "1.17.6", "systemjs-builder": "^0.15.34", "tether": "^1.4.0", - "tether-drop": "git://github.com/torkelo/drop", + "tether-drop": "https://github.com/torkelo/drop", "tslint": "^4.0.2", "typescript": "^2.1.4", "virtual-scroll": "^1.1.1" diff --git a/packaging/deb/control/postinst b/packaging/deb/control/postinst index 425a7319e62..7cddfd31857 100755 --- a/packaging/deb/control/postinst +++ b/packaging/deb/control/postinst @@ -42,6 +42,12 @@ case "$1" in chown -R $GRAFANA_USER:$GRAFANA_GROUP /var/log/grafana /var/lib/grafana chmod 755 /var/log/grafana /var/lib/grafana + # copy user config files + if [ ! -f $CONF_FILE ]; then + cp /usr/share/grafana/conf/sample.ini $CONF_FILE + cp /usr/share/grafana/conf/ldap.toml /etc/grafana/ldap.toml + fi + # configuration files should not be modifiable by grafana user, as this can be a security issue chown -Rh root:$GRAFANA_GROUP /etc/grafana/* chmod 755 /etc/grafana diff --git a/packaging/deb/default/grafana-server b/packaging/deb/default/grafana-server index dd06906b903..cc5ee866f7d 100644 --- a/packaging/deb/default/grafana-server +++ b/packaging/deb/default/grafana-server @@ -14,6 +14,6 @@ CONF_DIR=/etc/grafana CONF_FILE=/etc/grafana/grafana.ini -RESTART_ON_UPGRADE=false +RESTART_ON_UPGRADE=true PLUGINS_DIR=/var/lib/grafana/plugins diff --git a/packaging/publish/publish_both.sh b/packaging/publish/publish_both.sh index 57adcbd3481..2f24ff6b3ea 100755 --- a/packaging/publish/publish_both.sh +++ b/packaging/publish/publish_both.sh @@ -1,6 +1,6 @@ #! /usr/bin/env bash -deb_ver=4.0.2-1481203731 -rpm_ver=4.0.2-1481203731 +deb_ver=4.1.0-1484127817 +rpm_ver=4.1.0-1484127817 wget https://grafanarel.s3.amazonaws.com/builds/grafana_${deb_ver}_amd64.deb diff --git a/packaging/publish/publish_testing.sh b/packaging/publish/publish_testing.sh old mode 100644 new mode 100755 index b6277ebee69..1e39d65b7e5 --- a/packaging/publish/publish_testing.sh +++ b/packaging/publish/publish_testing.sh @@ -1,6 +1,6 @@ #! /usr/bin/env bash -deb_ver=4.0.2-1481203731 -rpm_ver=4.0.2-1481203731 +deb_ver=4.1.0-1482230757beta1 +rpm_ver=4.1.0-1482230757beta1 wget https://grafanarel.s3.amazonaws.com/builds/grafana_${deb_ver}_amd64.deb diff --git a/packaging/rpm/control/postinst b/packaging/rpm/control/postinst index fce80719115..be3d7692a5f 100644 --- a/packaging/rpm/control/postinst +++ b/packaging/rpm/control/postinst @@ -6,6 +6,7 @@ set -e startGrafana() { if [ -x /bin/systemctl ] ; then + /bin/systemctl daemon-reload /bin/systemctl start grafana-server.service elif [ -x /etc/init.d/grafana-server ] ; then /etc/init.d/grafana-server start @@ -37,6 +38,12 @@ if [ $1 -eq 1 ] ; then -c "grafana user" grafana fi + # copy user config files + if [ ! -f $CONF_FILE ]; then + cp /usr/share/grafana/conf/sample.ini $CONF_FILE + cp /usr/share/grafana/conf/ldap.toml /etc/grafana/ldap.toml + fi + # Set user permissions on /var/log/grafana, /var/lib/grafana mkdir -p /var/log/grafana /var/lib/grafana chown -R $GRAFANA_USER:$GRAFANA_GROUP /var/log/grafana /var/lib/grafana diff --git a/packaging/rpm/sysconfig/grafana-server b/packaging/rpm/sysconfig/grafana-server index dd06906b903..cc5ee866f7d 100644 --- a/packaging/rpm/sysconfig/grafana-server +++ b/packaging/rpm/sysconfig/grafana-server @@ -14,6 +14,6 @@ CONF_DIR=/etc/grafana CONF_FILE=/etc/grafana/grafana.ini -RESTART_ON_UPGRADE=false +RESTART_ON_UPGRADE=true PLUGINS_DIR=/var/lib/grafana/plugins diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 6e48b477543..5652196b58c 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -73,9 +73,9 @@ func GetAlerts(c *middleware.Context) Response { Name: alert.Name, Message: alert.Message, State: alert.State, - EvalDate: alert.EvalDate, NewStateDate: alert.NewStateDate, ExecutionError: alert.ExecutionError, + EvalData: alert.EvalData, }) } @@ -121,10 +121,10 @@ func AlertTest(c *middleware.Context, dto dtos.AlertTestCommand) Response { } res := backendCmd.Result - dtoRes := &dtos.AlertTestResult{ Firing: res.Firing, ConditionEvals: res.ConditionEvals, + State: res.Rule.State, } if res.Error != nil { @@ -173,6 +173,10 @@ func DelAlert(c *middleware.Context) Response { return Json(200, resp) } +func GetAlertNotifiers(c *middleware.Context) Response { + return Json(200, alerting.GetNotifiers()) +} + func GetAlertNotifications(c *middleware.Context) Response { query := &models.GetAllAlertNotificationsQuery{OrgId: c.OrgId} diff --git a/pkg/api/api.go b/pkg/api/api.go index de9778953d4..7d3a5563892 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -125,6 +125,8 @@ func (hs *HttpServer) registerRoutes() { r.Get("/", wrap(SearchUsers)) r.Get("/:id", wrap(GetUserById)) r.Get("/:id/orgs", wrap(GetUserOrgList)) + // query parameters /users/lookup?loginOrEmail=admin@example.com + r.Get("/lookup", wrap(GetUserByLoginOrEmail)) r.Put("/:id", bind(m.UpdateUserCommand{}), wrap(UpdateUser)) r.Post("/:id/using/:orgId", wrap(UpdateUserActiveOrg)) }, reqGrafanaAdmin) @@ -261,6 +263,7 @@ func (hs *HttpServer) registerRoutes() { }) r.Get("/alert-notifications", wrap(GetAlertNotifications)) + r.Get("/alert-notifiers", wrap(GetAlertNotifiers)) r.Group("/alert-notifications", func() { r.Post("/test", bind(dtos.NotificationTestCommand{}), wrap(NotificationTest)) diff --git a/pkg/api/cloudwatch/cloudwatch.go b/pkg/api/cloudwatch/cloudwatch.go index 0fb7b09f3cb..1b796be874e 100644 --- a/pkg/api/cloudwatch/cloudwatch.go +++ b/pkg/api/cloudwatch/cloudwatch.go @@ -17,7 +17,6 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/sts" - "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -90,7 +89,7 @@ type cache struct { var awsCredentialCache map[string]cache = make(map[string]cache) var credentialCacheLock sync.RWMutex -func getCredentials(dsInfo *datasourceInfo) *credentials.Credentials { +func getCredentials(dsInfo *datasourceInfo) (*credentials.Credentials, error) { cacheKey := dsInfo.Profile + ":" + dsInfo.AssumeRoleArn credentialCacheLock.RLock() if _, ok := awsCredentialCache[cacheKey]; ok { @@ -98,7 +97,7 @@ func getCredentials(dsInfo *datasourceInfo) *credentials.Credentials { (*awsCredentialCache[cacheKey].expiration).After(time.Now().UTC()) { result := awsCredentialCache[cacheKey].credential credentialCacheLock.RUnlock() - return result + return result, nil } } credentialCacheLock.RUnlock() @@ -130,8 +129,7 @@ func getCredentials(dsInfo *datasourceInfo) *credentials.Credentials { svc := sts.New(session.New(stsConfig), stsConfig) resp, err := svc.AssumeRole(params) if err != nil { - // ignore - log.Error(3, "CloudWatch: Failed to assume role", err) + return nil, err } if resp.Credentials != nil { accessKeyId = *resp.Credentials.AccessKeyId @@ -165,19 +163,28 @@ func getCredentials(dsInfo *datasourceInfo) *credentials.Credentials { } credentialCacheLock.Unlock() - return creds + return creds, nil } -func getAwsConfig(req *cwRequest) *aws.Config { +func getAwsConfig(req *cwRequest) (*aws.Config, error) { + creds, err := getCredentials(req.GetDatasourceInfo()) + if err != nil { + return nil, err + } + cfg := &aws.Config{ Region: aws.String(req.Region), - Credentials: getCredentials(req.GetDatasourceInfo()), + Credentials: creds, } - return cfg + return cfg, nil } func handleGetMetricStatistics(req *cwRequest, c *middleware.Context) { - cfg := getAwsConfig(req) + cfg, err := getAwsConfig(req) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } svc := cloudwatch.New(session.New(cfg), cfg) reqParam := &struct { @@ -220,7 +227,11 @@ func handleGetMetricStatistics(req *cwRequest, c *middleware.Context) { } func handleListMetrics(req *cwRequest, c *middleware.Context) { - cfg := getAwsConfig(req) + cfg, err := getAwsConfig(req) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } svc := cloudwatch.New(session.New(cfg), cfg) reqParam := &struct { @@ -239,7 +250,7 @@ func handleListMetrics(req *cwRequest, c *middleware.Context) { } var resp cloudwatch.ListMetricsOutput - err := svc.ListMetricsPages(params, + err = svc.ListMetricsPages(params, func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool { metrics.M_Aws_CloudWatch_ListMetrics.Inc(1) metrics, _ := awsutil.ValuesAtPath(page, "Metrics") @@ -257,7 +268,11 @@ func handleListMetrics(req *cwRequest, c *middleware.Context) { } func handleDescribeAlarms(req *cwRequest, c *middleware.Context) { - cfg := getAwsConfig(req) + cfg, err := getAwsConfig(req) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } svc := cloudwatch.New(session.New(cfg), cfg) reqParam := &struct { @@ -296,7 +311,11 @@ func handleDescribeAlarms(req *cwRequest, c *middleware.Context) { } func handleDescribeAlarmsForMetric(req *cwRequest, c *middleware.Context) { - cfg := getAwsConfig(req) + cfg, err := getAwsConfig(req) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } svc := cloudwatch.New(session.New(cfg), cfg) reqParam := &struct { @@ -336,7 +355,11 @@ func handleDescribeAlarmsForMetric(req *cwRequest, c *middleware.Context) { } func handleDescribeAlarmHistory(req *cwRequest, c *middleware.Context) { - cfg := getAwsConfig(req) + cfg, err := getAwsConfig(req) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } svc := cloudwatch.New(session.New(cfg), cfg) reqParam := &struct { @@ -368,7 +391,11 @@ func handleDescribeAlarmHistory(req *cwRequest, c *middleware.Context) { } func handleDescribeInstances(req *cwRequest, c *middleware.Context) { - cfg := getAwsConfig(req) + cfg, err := getAwsConfig(req) + if err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } svc := ec2.New(session.New(cfg), cfg) reqParam := &struct { @@ -388,7 +415,7 @@ func handleDescribeInstances(req *cwRequest, c *middleware.Context) { } var resp ec2.DescribeInstancesOutput - err := svc.DescribeInstancesPages(params, + err = svc.DescribeInstancesPages(params, func(page *ec2.DescribeInstancesOutput, lastPage bool) bool { reservations, _ := awsutil.ValuesAtPath(page, "Reservations") for _, reservation := range reservations { diff --git a/pkg/api/cloudwatch/metrics.go b/pkg/api/cloudwatch/metrics.go index 9a39a29a760..0df8eaa089c 100644 --- a/pkg/api/cloudwatch/metrics.go +++ b/pkg/api/cloudwatch/metrics.go @@ -111,7 +111,7 @@ func init() { "AWS/ElasticMapReduce": {"ClusterId", "JobFlowId", "JobId"}, "AWS/ES": {"ClientId", "DomainName"}, "AWS/Events": {"RuleName"}, - "AWS/Firehose": {}, + "AWS/Firehose": {"DeliveryStreamName"}, "AWS/IoT": {"Protocol"}, "AWS/Kinesis": {"StreamName", "ShardID"}, "AWS/KinesisAnalytics": {"Flow", "Id", "Application"}, @@ -140,8 +140,8 @@ func init() { // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html func handleGetRegions(req *cwRequest, c *middleware.Context) { regions := []string{ - "ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "cn-north-1", - "eu-central-1", "eu-west-1", "sa-east-1", "us-east-1", "us-west-1", "us-west-2", "us-gov-west-1", + "ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-south-1", "ca-central-1", "cn-north-1", + "eu-central-1", "eu-west-1", "eu-west-2", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2", } result := []interface{}{} @@ -248,9 +248,13 @@ func handleGetDimensions(req *cwRequest, c *middleware.Context) { } func getAllMetrics(cwData *datasourceInfo) (cloudwatch.ListMetricsOutput, error) { + creds, err := getCredentials(cwData) + if err != nil { + return cloudwatch.ListMetricsOutput{}, err + } cfg := &aws.Config{ Region: aws.String(cwData.Region), - Credentials: getCredentials(cwData), + Credentials: creds, } svc := cloudwatch.New(session.New(cfg), cfg) @@ -260,7 +264,7 @@ func getAllMetrics(cwData *datasourceInfo) (cloudwatch.ListMetricsOutput, error) } var resp cloudwatch.ListMetricsOutput - err := svc.ListMetricsPages(params, + err = svc.ListMetricsPages(params, func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool { metrics.M_Aws_CloudWatch_ListMetrics.Inc(1) metrics, _ := awsutil.ValuesAtPath(page, "Metrics") diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index db4c5166feb..dfdc867d4a4 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -1,6 +1,8 @@ package api import ( + "bytes" + "io/ioutil" "net/http" "net/http/httputil" "net/url" @@ -8,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/cloudwatch" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" @@ -15,6 +18,10 @@ import ( "github.com/grafana/grafana/pkg/util" ) +var ( + dataproxyLogger log.Logger = log.New("data-proxy-log") +) + func NewReverseProxy(ds *m.DataSource, proxyPath string, targetUrl *url.URL) *httputil.ReverseProxy { director := func(req *http.Request) { req.URL.Scheme = targetUrl.Scheme @@ -121,6 +128,32 @@ func ProxyDataSourceRequest(c *middleware.Context) { c.JsonApiErr(400, "Unable to load TLS certificate", err) return } + + logProxyRequest(ds.Type, c) proxy.ServeHTTP(c.Resp, c.Req.Request) c.Resp.Header().Del("Set-Cookie") } + +func logProxyRequest(dataSourceType string, c *middleware.Context) { + if !setting.DataProxyLogging { + return + } + + var body string + if c.Req.Request.Body != nil { + buffer, err := ioutil.ReadAll(c.Req.Request.Body) + if err == nil { + c.Req.Request.Body = ioutil.NopCloser(bytes.NewBuffer(buffer)) + body = string(buffer) + } + } + + dataproxyLogger.Info("Proxying incoming request", + "userid", c.UserId, + "orgid", c.OrgId, + "username", c.Login, + "datasource", dataSourceType, + "uri", c.Req.RequestURI, + "method", c.Req.Request.Method, + "body", body) +} diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 707e351d6b5..43f4d308ed6 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -100,7 +100,7 @@ func AddDataSource(c *middleware.Context, cmd m.AddDataSourceCommand) { return } - c.JSON(200, util.DynMap{"message": "Datasource added", "id": cmd.Result.Id}) + c.JSON(200, util.DynMap{"message": "Datasource added", "id": cmd.Result.Id, "name": cmd.Result.Name}) } func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) Response { @@ -117,7 +117,7 @@ func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) Resp return ApiError(500, "Failed to update datasource", err) } - return Json(200, util.DynMap{"message": "Datasource updated"}) + return Json(200, util.DynMap{"message": "Datasource updated", "id": cmd.Id, "name": cmd.Name}) } func fillWithSecureJsonData(cmd *m.UpdateDataSourceCommand) error { diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index f3d64f89524..4285ebc89cc 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -3,6 +3,7 @@ package dtos import ( "time" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" ) @@ -16,6 +17,7 @@ type AlertRule struct { State m.AlertStateType `json:"state"` NewStateDate time.Time `json:"newStateDate"` EvalDate time.Time `json:"evalDate"` + EvalData *simplejson.Json `json:"evalData"` ExecutionError string `json:"executionError"` DashbboardUri string `json:"dashboardUri"` } @@ -36,6 +38,7 @@ type AlertTestCommand struct { type AlertTestResult struct { Firing bool `json:"firing"` + State m.AlertStateType `json:"state"` ConditionEvals string `json:"conditionEvals"` TimeMs string `json:"timeMs"` Error string `json:"error,omitempty"` @@ -51,7 +54,7 @@ type AlertTestResultLog struct { type EvalMatch struct { Tags map[string]string `json:"tags,omitempty"` Metric string `json:"metric"` - Value float64 `json:"value"` + Value null.Float `json:"value"` } type NotificationTestCommand struct { diff --git a/pkg/api/dtos/user.go b/pkg/api/dtos/user.go index dbbe24a159b..2ffe9d69236 100644 --- a/pkg/api/dtos/user.go +++ b/pkg/api/dtos/user.go @@ -31,7 +31,7 @@ type AdminUpdateUserPasswordForm struct { } type AdminUpdateUserPermissionsForm struct { - IsGrafanaAdmin bool `json:"IsGrafanaAdmin"` + IsGrafanaAdmin bool `json:"isGrafanaAdmin" binding:"Required"` } type AdminUserListItem struct { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 1fd4cd347e7..3690784375d 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -140,6 +140,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro "allowOrgCreate": (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, "authProxyEnabled": setting.AuthProxyEnabled, "ldapEnabled": setting.LdapEnabled, + "alertingEnabled": setting.AlertingEnabled, "buildInfo": map[string]interface{}{ "version": setting.BuildVersion, "commit": setting.BuildCommit, diff --git a/pkg/api/index.go b/pkg/api/index.go index 5bc4344a8ba..bf7a9fc1759 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -103,10 +103,10 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { Children: dashboardChildNavs, }) - if c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR { + if setting.AlertingEnabled && (c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR) { alertChildNavs := []*dtos.NavLink{ {Text: "Alert List", Url: setting.AppSubUrl + "/alerting/list"}, - {Text: "Notifications", Url: setting.AppSubUrl + "/alerting/notifications"}, + {Text: "Notification channels", Url: setting.AppSubUrl + "/alerting/notifications"}, } data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ diff --git a/pkg/api/render.go b/pkg/api/render.go index 6018656badb..4dbead23524 100644 --- a/pkg/api/render.go +++ b/pkg/api/render.go @@ -14,11 +14,12 @@ func RenderToPng(c *middleware.Context) { queryParams := fmt.Sprintf("?%s", c.Req.URL.RawQuery) renderOpts := &renderer.RenderOpts{ - Path: c.Params("*") + queryParams, - Width: queryReader.Get("width", "800"), - Height: queryReader.Get("height", "400"), - OrgId: c.OrgId, - Timeout: queryReader.Get("timeout", "30"), + Path: c.Params("*") + queryParams, + Width: queryReader.Get("width", "800"), + Height: queryReader.Get("height", "400"), + OrgId: c.OrgId, + Timeout: queryReader.Get("timeout", "30"), + Timezone: queryReader.Get("tz", ""), } pngPath, err := renderer.RenderToPng(renderOpts) diff --git a/pkg/api/user.go b/pkg/api/user.go index a22ad028288..7bce599d692 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -13,7 +13,7 @@ func GetSignedInUser(c *middleware.Context) Response { return getUserUserProfile(c.UserId) } -// GET /api/user/:id +// GET /api/users/:id func GetUserById(c *middleware.Context) Response { return getUserUserProfile(c.ParamsInt64(":id")) } @@ -22,12 +22,36 @@ func getUserUserProfile(userId int64) Response { query := m.GetUserProfileQuery{UserId: userId} if err := bus.Dispatch(&query); err != nil { + if err == m.ErrUserNotFound { + return ApiError(404, m.ErrUserNotFound.Error(), nil) + } return ApiError(500, "Failed to get user", err) } return Json(200, query.Result) } +// GET /api/users/lookup +func GetUserByLoginOrEmail(c *middleware.Context) Response { + query := m.GetUserByLoginQuery{LoginOrEmail: c.Query("loginOrEmail")} + if err := bus.Dispatch(&query); err != nil { + if err == m.ErrUserNotFound { + return ApiError(404, m.ErrUserNotFound.Error(), nil) + } + return ApiError(500, "Failed to get user", err) + } + user := query.Result + result := m.UserProfileDTO{ + Name: user.Name, + Email: user.Email, + Login: user.Login, + Theme: user.Theme, + IsGrafanaAdmin: user.IsAdmin, + OrgId: user.OrgId, + } + return Json(200, &result) +} + // POST /api/user func UpdateSignedInUser(c *middleware.Context, cmd m.UpdateUserCommand) Response { if setting.AuthProxyEnabled { @@ -60,7 +84,7 @@ func UpdateUserActiveOrg(c *middleware.Context) Response { cmd := m.SetUsingOrgCommand{UserId: userId, OrgId: orgId} if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed change active organization", err) + return ApiError(500, "Failed to change active organization", err) } return ApiSuccess("Active organization changed") @@ -70,12 +94,12 @@ func handleUpdateUser(cmd m.UpdateUserCommand) Response { if len(cmd.Login) == 0 { cmd.Login = cmd.Email if len(cmd.Login) == 0 { - return ApiError(400, "Validation error, need specify either username or email", nil) + return ApiError(400, "Validation error, need to specify either username or email", nil) } } if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "failed to update user", err) + return ApiError(500, "Failed to update user", err) } return ApiSuccess("User updated") @@ -95,7 +119,7 @@ func getUserOrgList(userId int64) Response { query := m.GetUserOrgListQuery{UserId: userId} if err := bus.Dispatch(&query); err != nil { - return ApiError(500, "Faile to get user organziations", err) + return ApiError(500, "Failed to get user organizations", err) } return Json(200, query.Result) @@ -130,7 +154,7 @@ func UserSetUsingOrg(c *middleware.Context) Response { cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgId} if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed change active organization", err) + return ApiError(500, "Failed to change active organization", err) } return ApiSuccess("Active organization changed") diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go index 326566b00ef..94eb8e845ea 100644 --- a/pkg/cmd/grafana-server/server.go +++ b/pkg/cmd/grafana-server/server.go @@ -55,7 +55,7 @@ func (g *GrafanaServerImpl) Start() { plugins.Init() // init alerting - if setting.ExecuteAlerts { + if setting.AlertingEnabled && setting.ExecuteAlerts { engine := alerting.NewEngine() g.childRoutines.Go(func() error { return engine.Run(g.context) }) } diff --git a/pkg/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go index 1cbe55c8572..94cde098297 100644 --- a/pkg/components/imguploader/imguploader.go +++ b/pkg/components/imguploader/imguploader.go @@ -2,6 +2,7 @@ package imguploader import ( "fmt" + "regexp" "github.com/grafana/grafana/pkg/setting" ) @@ -30,19 +31,21 @@ func NewImageUploader() (ImageUploader, error) { accessKey := s3sec.Key("access_key").MustString("") secretKey := s3sec.Key("secret_key").MustString("") - if bucket == "" { + region := "" + rBucket := regexp.MustCompile(`https?:\/\/(.*)\.s3(-([^.]+))?\.amazonaws\.com\/?`) + matches := rBucket.FindStringSubmatch(bucket) + if len(matches) == 0 { return nil, fmt.Errorf("Could not find bucket setting for image.uploader.s3") + } else { + bucket = matches[1] + if matches[3] != "" { + region = matches[3] + } else { + region = "us-east-1" + } } - if accessKey == "" { - return nil, fmt.Errorf("Could not find accessKey setting for image.uploader.s3") - } - - if secretKey == "" { - return nil, fmt.Errorf("Could not find secretKey setting for image.uploader.s3") - } - - return NewS3Uploader(bucket, accessKey, secretKey), nil + return NewS3Uploader(region, bucket, "public-read", accessKey, secretKey), nil case "webdav": webdavSec, err := setting.Cfg.GetSection("external_image_storage.webdav") if err != nil { diff --git a/pkg/components/imguploader/imguploader_test.go b/pkg/components/imguploader/imguploader_test.go index 4a18f22c173..b871aa55a64 100644 --- a/pkg/components/imguploader/imguploader_test.go +++ b/pkg/components/imguploader/imguploader_test.go @@ -19,7 +19,7 @@ func TestImageUploaderFactory(t *testing.T) { setting.ImageUploadProvider = "s3" s3sec, err := setting.Cfg.GetSection("external_image_storage.s3") - s3sec.NewKey("bucket_url", "bucket_url") + s3sec.NewKey("bucket_url", "https://foo.bar.baz.s3-us-east-2.amazonaws.com") s3sec.NewKey("access_key", "access_key") s3sec.NewKey("secret_key", "secret_key") @@ -29,9 +29,10 @@ func TestImageUploaderFactory(t *testing.T) { original, ok := uploader.(*S3Uploader) So(ok, ShouldBeTrue) + So(original.region, ShouldEqual, "us-east-2") + So(original.bucket, ShouldEqual, "foo.bar.baz") So(original.accessKey, ShouldEqual, "access_key") So(original.secretKey, ShouldEqual, "secret_key") - So(original.bucket, ShouldEqual, "bucket_url") }) Convey("Webdav uploader", func() { diff --git a/pkg/components/imguploader/s3uploader.go b/pkg/components/imguploader/s3uploader.go index 59ec598412b..5f476b9e366 100644 --- a/pkg/components/imguploader/s3uploader.go +++ b/pkg/components/imguploader/s3uploader.go @@ -1,26 +1,33 @@ package imguploader import ( - "io/ioutil" - "net/http" - "net/url" - "path" + "os" + "time" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" + "github.com/aws/aws-sdk-go/aws/ec2metadata" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/util" - "github.com/kr/s3/s3util" ) type S3Uploader struct { + region string bucket string + acl string secretKey string accessKey string log log.Logger } -func NewS3Uploader(bucket, accessKey, secretKey string) *S3Uploader { +func NewS3Uploader(region, bucket, acl, accessKey, secretKey string) *S3Uploader { return &S3Uploader{ + region: region, bucket: bucket, + acl: acl, accessKey: accessKey, secretKey: secretKey, log: log.New("s3uploader"), @@ -28,42 +35,41 @@ func NewS3Uploader(bucket, accessKey, secretKey string) *S3Uploader { } func (u *S3Uploader) Upload(imageDiskPath string) (string, error) { - - s3util.DefaultConfig.AccessKey = u.accessKey - s3util.DefaultConfig.SecretKey = u.secretKey - - header := make(http.Header) - header.Add("x-amz-acl", "public-read") - header.Add("Content-Type", "image/png") - - var imageUrl *url.URL - var err error - - if imageUrl, err = url.Parse(u.bucket); err != nil { - return "", err + sess := session.New() + creds := credentials.NewChainCredentials( + []credentials.Provider{ + &credentials.StaticProvider{Value: credentials.Value{ + AccessKeyID: u.accessKey, + SecretAccessKey: u.secretKey, + }}, + &credentials.EnvProvider{}, + &ec2rolecreds.EC2RoleProvider{Client: ec2metadata.New(sess), ExpiryWindow: 5 * time.Minute}, + }) + cfg := &aws.Config{ + Region: aws.String(u.region), + Credentials: creds, } - // add image to url - imageUrl.Path = path.Join(imageUrl.Path, util.GetRandomString(20)+".png") - imageUrlString := imageUrl.String() - log.Debug("Uploading image to s3", "url", imageUrlString) + key := util.GetRandomString(20) + ".png" + log.Debug("Uploading image to s3", "bucket = ", u.bucket, ", key = ", key) - writer, err := s3util.Create(imageUrlString, header, nil) + file, err := os.Open(imageDiskPath) if err != nil { return "", err } - defer writer.Close() - - imgData, err := ioutil.ReadFile(imageDiskPath) + svc := s3.New(session.New(cfg), cfg) + params := &s3.PutObjectInput{ + Bucket: aws.String(u.bucket), + Key: aws.String(key), + ACL: aws.String(u.acl), + Body: file, + ContentType: aws.String("image/png"), + } + _, err = svc.PutObject(params) if err != nil { return "", err } - _, err = writer.Write(imgData) - if err != nil { - return "", err - } - - return imageUrlString, nil + return "https://" + u.bucket + ".s3.amazonaws.com/" + key, nil } diff --git a/vendor/gopkg.in/guregu/null.v3/float.go b/pkg/components/null/float.go similarity index 92% rename from vendor/gopkg.in/guregu/null.v3/float.go rename to pkg/components/null/float.go index 1f57b959ab7..1e78946e878 100644 --- a/vendor/gopkg.in/guregu/null.v3/float.go +++ b/pkg/components/null/float.go @@ -96,6 +96,16 @@ func (f Float) MarshalText() ([]byte, error) { return []byte(strconv.FormatFloat(f.Float64, 'f', -1, 64)), nil } +// MarshalText implements encoding.TextMarshaler. +// It will encode a blank string if this Float is null. +func (f Float) String() string { + if !f.Valid { + return "null" + } + + return fmt.Sprintf("%1.3f", f.Float64) +} + // SetValid changes this Float's value and also sets it to be non-null. func (f *Float) SetValid(n float64) { f.Float64 = n diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index 6db6a6c3d35..c09c431a1df 100644 --- a/pkg/components/renderer/renderer.go +++ b/pkg/components/renderer/renderer.go @@ -11,6 +11,8 @@ import ( "strconv" + "strings" + "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/setting" @@ -18,15 +20,38 @@ import ( ) type RenderOpts struct { - Path string - Width string - Height string - Timeout string - OrgId int64 + Path string + Width string + Height string + Timeout string + OrgId int64 + Timezone string } var rendererLog log.Logger = log.New("png-renderer") +func isoTimeOffsetToPosixTz(isoOffset string) string { + // invert offset + if strings.HasPrefix(isoOffset, "UTC+") { + return strings.Replace(isoOffset, "UTC+", "UTC-", 1) + } + if strings.HasPrefix(isoOffset, "UTC-") { + return strings.Replace(isoOffset, "UTC-", "UTC+", 1) + } + return isoOffset +} + +func appendEnviron(baseEnviron []string, name string, value string) []string { + results := make([]string, 0) + prefix := fmt.Sprintf("%s=", name) + for _, v := range baseEnviron { + if !strings.HasPrefix(v, prefix) { + results = append(results, v) + } + } + return append(results, fmt.Sprintf("%s=%s", name, value)) +} + func RenderToPng(params *RenderOpts) (string, error) { rendererLog.Info("Rendering", "path", params.Path) @@ -73,6 +98,11 @@ func RenderToPng(params *RenderOpts) (string, error) { return "", err } + if params.Timezone != "" { + baseEnviron := os.Environ() + cmd.Env = appendEnviron(baseEnviron, "TZ", isoTimeOffsetToPosixTz(params.Timezone)) + } + err = cmd.Start() if err != nil { return "", err diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 6d6558f5271..1020f28f874 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -45,8 +45,11 @@ var ( M_Alerting_Notification_Sent_Email Counter M_Alerting_Notification_Sent_Webhook Counter M_Alerting_Notification_Sent_PagerDuty Counter + M_Alerting_Notification_Sent_LINE Counter M_Alerting_Notification_Sent_Victorops Counter M_Alerting_Notification_Sent_OpsGenie Counter + M_Alerting_Notification_Sent_Telegram Counter + M_Alerting_Notification_Sent_Sensu Counter M_Aws_CloudWatch_GetMetricStatistics Counter M_Aws_CloudWatch_ListMetrics Counter @@ -114,6 +117,9 @@ func initMetricVars(settings *MetricSettings) { M_Alerting_Notification_Sent_PagerDuty = RegCounter("alerting.notifications_sent", "type", "pagerduty") M_Alerting_Notification_Sent_Victorops = RegCounter("alerting.notifications_sent", "type", "victorops") M_Alerting_Notification_Sent_OpsGenie = RegCounter("alerting.notifications_sent", "type", "opsgenie") + M_Alerting_Notification_Sent_Telegram = RegCounter("alerting.notifications_sent", "type", "telegram") + M_Alerting_Notification_Sent_Sensu = RegCounter("alerting.notifications_sent", "type", "sensu") + M_Alerting_Notification_Sent_LINE = RegCounter("alerting.notifications_sent", "type", "LINE") M_Aws_CloudWatch_GetMetricStatistics = RegCounter("aws.cloudwatch.get_metric_statistics") M_Aws_CloudWatch_ListMetrics = RegCounter("aws.cloudwatch.list_metrics") diff --git a/pkg/models/alert.go b/pkg/models/alert.go index 13cae0715f7..61976677764 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -73,7 +73,6 @@ type Alert struct { Frequency int64 EvalData *simplejson.Json - EvalDate time.Time NewStateDate time.Time StateChanges int diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index 42ebda9ea44..ad7aed3bc50 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -23,6 +23,7 @@ type SendWebhookSync struct { Password string Body string HttpMethod string + HttpHeader map[string]string } type SendResetPasswordEmailCommand struct { diff --git a/pkg/services/alerting/conditions/evaluator.go b/pkg/services/alerting/conditions/evaluator.go index 57abb2edd25..1b8fb952f65 100644 --- a/pkg/services/alerting/conditions/evaluator.go +++ b/pkg/services/alerting/conditions/evaluator.go @@ -3,9 +3,9 @@ package conditions import ( "encoding/json" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/alerting" - "gopkg.in/guregu/null.v3" ) var ( diff --git a/pkg/services/alerting/conditions/evaluator_test.go b/pkg/services/alerting/conditions/evaluator_test.go index e2a3cb48396..a97e83247ef 100644 --- a/pkg/services/alerting/conditions/evaluator_test.go +++ b/pkg/services/alerting/conditions/evaluator_test.go @@ -3,10 +3,10 @@ package conditions import ( "testing" - "gopkg.in/guregu/null.v3" - - "github.com/grafana/grafana/pkg/components/simplejson" . "github.com/smartystreets/goconvey/convey" + + "github.com/grafana/grafana/pkg/components/null" + "github.com/grafana/grafana/pkg/components/simplejson" ) func evalutorScenario(json string, reducedValue float64, datapoints ...float64) bool { diff --git a/pkg/services/alerting/conditions/query.go b/pkg/services/alerting/conditions/query.go index d7e0a0c29e3..eafb233a60b 100644 --- a/pkg/services/alerting/conditions/query.go +++ b/pkg/services/alerting/conditions/query.go @@ -6,6 +6,7 @@ import ( "time" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" @@ -45,18 +46,18 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) (*alerting.Conditio emptySerieCount := 0 evalMatchCount := 0 var matches []*alerting.EvalMatch + for _, series := range seriesList { reducedValue := c.Reducer.Reduce(series) evalMatch := c.Evaluator.Eval(reducedValue) if reducedValue.Valid == false { emptySerieCount++ - continue } if context.IsTestRun { context.Logs = append(context.Logs, &alerting.ResultLogEntry{ - Message: fmt.Sprintf("Condition[%d]: Eval: %v, Metric: %s, Value: %1.3f", c.Index, evalMatch, series.Name, reducedValue.Float64), + Message: fmt.Sprintf("Condition[%d]: Eval: %v, Metric: %s, Value: %s", c.Index, evalMatch, series.Name, reducedValue), }) } @@ -65,11 +66,28 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) (*alerting.Conditio matches = append(matches, &alerting.EvalMatch{ Metric: series.Name, - Value: reducedValue.Float64, + Value: reducedValue, }) } } + // handle no series special case + if len(seriesList) == 0 { + // eval condition for null value + evalMatch := c.Evaluator.Eval(null.FloatFromPtr(nil)) + + if context.IsTestRun { + context.Logs = append(context.Logs, &alerting.ResultLogEntry{ + Message: fmt.Sprintf("Condition[%d]: Eval: %v, Query Returned No Series (reduced to null/no value)", evalMatch), + }) + } + + if evalMatch { + evalMatchCount++ + matches = append(matches, &alerting.EvalMatch{Metric: "NoData", Value: null.FloatFromPtr(nil)}) + } + } + return &alerting.ConditionResult{ Firing: evalMatchCount > 0, NoDataFound: emptySerieCount == len(seriesList), diff --git a/pkg/services/alerting/conditions/query_test.go b/pkg/services/alerting/conditions/query_test.go index c3797beaf37..17b47b2832b 100644 --- a/pkg/services/alerting/conditions/query_test.go +++ b/pkg/services/alerting/conditions/query_test.go @@ -4,9 +4,8 @@ import ( "context" "testing" - null "gopkg.in/guregu/null.v3" - "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" @@ -72,7 +71,38 @@ func TestQueryCondition(t *testing.T) { So(cr.Firing, ShouldBeTrue) }) + Convey("No series", func() { + Convey("Should set NoDataFound when condition is gt", func() { + ctx.series = tsdb.TimeSeriesSlice{} + cr, err := ctx.exec() + + So(err, ShouldBeNil) + So(cr.Firing, ShouldBeFalse) + So(cr.NoDataFound, ShouldBeTrue) + }) + + Convey("Should be firing when condition is no_value", func() { + ctx.evaluator = `{"type": "no_value", "params": []}` + ctx.series = tsdb.TimeSeriesSlice{} + cr, err := ctx.exec() + + So(err, ShouldBeNil) + So(cr.Firing, ShouldBeTrue) + }) + }) + Convey("Empty series", func() { + Convey("Should set Firing if eval match", func() { + ctx.evaluator = `{"type": "no_value", "params": []}` + ctx.series = tsdb.TimeSeriesSlice{ + tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs()), + } + cr, err := ctx.exec() + + So(err, ShouldBeNil) + So(cr.Firing, ShouldBeTrue) + }) + Convey("Should set NoDataFound both series are empty", func() { ctx.series = tsdb.TimeSeriesSlice{ tsdb.NewTimeSeries("test1", tsdb.NewTimeSeriesPointsFromArgs()), diff --git a/pkg/services/alerting/conditions/reducer.go b/pkg/services/alerting/conditions/reducer.go index b7816a61d1d..0f396e2ffc3 100644 --- a/pkg/services/alerting/conditions/reducer.go +++ b/pkg/services/alerting/conditions/reducer.go @@ -5,8 +5,8 @@ import ( "sort" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/tsdb" - "gopkg.in/guregu/null.v3" ) type QueryReducer interface { diff --git a/pkg/services/alerting/conditions/reducer_test.go b/pkg/services/alerting/conditions/reducer_test.go index 7d6cee466fc..ff6105a06ec 100644 --- a/pkg/services/alerting/conditions/reducer_test.go +++ b/pkg/services/alerting/conditions/reducer_test.go @@ -3,10 +3,10 @@ package conditions import ( "testing" - "gopkg.in/guregu/null.v3" - - "github.com/grafana/grafana/pkg/tsdb" . "github.com/smartystreets/goconvey/convey" + + "github.com/grafana/grafana/pkg/components/null" + "github.com/grafana/grafana/pkg/tsdb" ) func TestSimpleReducer(t *testing.T) { @@ -57,6 +57,16 @@ func TestSimpleReducer(t *testing.T) { So(result, ShouldEqual, float64(2)) }) + Convey("avg with only nulls", func() { + reducer := NewSimpleReducer("avg") + series := &tsdb.TimeSeries{ + Name: "test time serie", + } + + series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), 1)) + So(reducer.Reduce(series).Valid, ShouldEqual, false) + }) + Convey("avg of number values and null values should ignore nulls", func() { reducer := NewSimpleReducer("avg") series := &tsdb.TimeSeries{ diff --git a/pkg/services/alerting/eval_handler.go b/pkg/services/alerting/eval_handler.go index 867226738b8..4958cab097b 100644 --- a/pkg/services/alerting/eval_handler.go +++ b/pkg/services/alerting/eval_handler.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/metrics" + "github.com/grafana/grafana/pkg/models" ) type DefaultEvalHandler struct { @@ -60,6 +61,40 @@ func (e *DefaultEvalHandler) Eval(context *EvalContext) { context.Firing = firing context.NoDataFound = noDataFound context.EndTime = time.Now() + context.Rule.State = e.getNewState(context) + elapsedTime := context.EndTime.Sub(context.StartTime) / time.Millisecond metrics.M_Alerting_Execution_Time.Update(elapsedTime) } + +// This should be move into evalContext once its been refactored. +func (handler *DefaultEvalHandler) getNewState(evalContext *EvalContext) models.AlertStateType { + if evalContext.Error != nil { + handler.log.Error("Alert Rule Result Error", + "ruleId", evalContext.Rule.Id, + "name", evalContext.Rule.Name, + "error", evalContext.Error, + "changing state to", evalContext.Rule.ExecutionErrorState.ToAlertState()) + + if evalContext.Rule.ExecutionErrorState == models.ExecutionErrorKeepState { + return evalContext.PrevAlertState + } else { + return evalContext.Rule.ExecutionErrorState.ToAlertState() + } + } else if evalContext.Firing { + return models.AlertStateAlerting + } else if evalContext.NoDataFound { + handler.log.Info("Alert Rule returned no data", + "ruleId", evalContext.Rule.Id, + "name", evalContext.Rule.Name, + "changing state to", evalContext.Rule.NoDataState.ToAlertState()) + + if evalContext.Rule.NoDataState == models.NoDataKeepState { + return evalContext.PrevAlertState + } else { + return evalContext.Rule.NoDataState.ToAlertState() + } + } + + return models.AlertStateOK +} diff --git a/pkg/services/alerting/eval_handler_test.go b/pkg/services/alerting/eval_handler_test.go index 73d675e3fa7..cf6422a5250 100644 --- a/pkg/services/alerting/eval_handler_test.go +++ b/pkg/services/alerting/eval_handler_test.go @@ -2,8 +2,10 @@ package alerting import ( "context" + "fmt" "testing" + "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" ) @@ -18,8 +20,8 @@ func (c *conditionStub) Eval(context *EvalContext) (*ConditionResult, error) { return &ConditionResult{Firing: c.firing, EvalMatches: c.matches, Operator: c.operator, NoDataFound: c.noData}, nil } -func TestAlertingExecutor(t *testing.T) { - Convey("Test alert execution", t, func() { +func TestAlertingEvaluationHandler(t *testing.T) { + Convey("Test alert evaluation handler", t, func() { handler := NewEvalHandler() Convey("Show return triggered with single passing condition", func() { @@ -37,7 +39,7 @@ func TestAlertingExecutor(t *testing.T) { Convey("Show return false with not passing asdf", func() { context := NewEvalContext(context.TODO(), &Rule{ Conditions: []Condition{ - &conditionStub{firing: true, operator: "and", matches: []*EvalMatch{&EvalMatch{}, &EvalMatch{}}}, + &conditionStub{firing: true, operator: "and", matches: []*EvalMatch{{}, {}}}, &conditionStub{firing: false, operator: "and"}, }, }) @@ -164,5 +166,73 @@ func TestAlertingExecutor(t *testing.T) { handler.Eval(context) So(context.NoDataFound, ShouldBeTrue) }) + + Convey("EvalHandler can replace alert state based for errors and no_data", func() { + ctx := NewEvalContext(context.TODO(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}) + dummieError := fmt.Errorf("dummie error") + Convey("Should update alert state", func() { + + Convey("ok -> alerting", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Firing = true + + So(handler.getNewState(ctx), ShouldEqual, models.AlertStateAlerting) + }) + + Convey("ok -> error(alerting)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Error = dummieError + ctx.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting + + ctx.Rule.State = handler.getNewState(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) + }) + + Convey("ok -> error(keep_last)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Error = dummieError + ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState + + ctx.Rule.State = handler.getNewState(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) + }) + + Convey("pending -> error(keep_last)", func() { + ctx.PrevAlertState = models.AlertStatePending + ctx.Error = dummieError + ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState + + ctx.Rule.State = handler.getNewState(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) + }) + + Convey("ok -> no_data(alerting)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Rule.NoDataState = models.NoDataSetAlerting + ctx.NoDataFound = true + + ctx.Rule.State = handler.getNewState(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) + }) + + Convey("ok -> no_data(keep_last)", func() { + ctx.PrevAlertState = models.AlertStateOK + ctx.Rule.NoDataState = models.NoDataKeepState + ctx.NoDataFound = true + + ctx.Rule.State = handler.getNewState(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) + }) + + Convey("pending -> no_data(keep_last)", func() { + ctx.PrevAlertState = models.AlertStatePending + ctx.Rule.NoDataState = models.NoDataKeepState + ctx.NoDataFound = true + + ctx.Rule.State = handler.getNewState(ctx) + So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) + }) + }) + }) }) } diff --git a/pkg/services/alerting/extractor.go b/pkg/services/alerting/extractor.go index 6e064c8cf76..28a6b8c61a0 100644 --- a/pkg/services/alerting/extractor.go +++ b/pkg/services/alerting/extractor.go @@ -60,12 +60,25 @@ func findPanelQueryByRefId(panel *simplejson.Json, refId string) *simplejson.Jso return nil } +func copyJson(in *simplejson.Json) (*simplejson.Json, error) { + rawJson, err := in.MarshalJSON() + if err != nil { + return nil, err + } + + return simplejson.NewJson(rawJson) +} + func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) { e.log.Debug("GetAlerts") - alerts := make([]*m.Alert, 0) + dashboardJson, err := copyJson(e.Dash.Data) + if err != nil { + return nil, err + } - for _, rowObj := range e.Dash.Data.Get("rows").MustArray() { + alerts := make([]*m.Alert, 0) + for _, rowObj := range dashboardJson.Get("rows").MustArray() { row := simplejson.NewFromAny(rowObj) for _, panelObj := range row.Get("panels").MustArray() { diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index e72e6d938d8..cb174783478 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -110,6 +110,34 @@ func TestAlertRuleExtraction(t *testing.T) { ] }` + Convey("Extractor should not modify the original json", func() { + dashJson, err := simplejson.NewJson([]byte(json)) + So(err, ShouldBeNil) + + dash := m.NewDashboardFromJson(dashJson) + + getTarget := func(j *simplejson.Json) string { + rowObj := j.Get("rows").MustArray()[0] + row := simplejson.NewFromAny(rowObj) + panelObj := row.Get("panels").MustArray()[0] + panel := simplejson.NewFromAny(panelObj) + conditionObj := panel.Get("alert").Get("conditions").MustArray()[0] + condition := simplejson.NewFromAny(conditionObj) + return condition.Get("query").Get("model").Get("target").MustString() + } + + Convey("Dashboard json rows.panels.alert.query.model.target should be empty", func() { + So(getTarget(dashJson), ShouldEqual, "") + }) + + extractor := NewDashAlertExtractor(dash, 1) + _, _ = extractor.GetAlerts() + + Convey("Dashboard json should not be updated after extracting rules", func() { + So(getTarget(dashJson), ShouldEqual, "") + }) + }) + Convey("Parsing and validating dashboard containing graphite alerts", func() { dashJson, err := simplejson.NewJson([]byte(json)) diff --git a/pkg/services/alerting/models.go b/pkg/services/alerting/models.go index 140c545fb2f..bbd8b98eea2 100644 --- a/pkg/services/alerting/models.go +++ b/pkg/services/alerting/models.go @@ -1,5 +1,7 @@ package alerting +import "github.com/grafana/grafana/pkg/components/null" + type Job struct { Offset int64 OffsetWait bool @@ -14,7 +16,7 @@ type ResultLogEntry struct { } type EvalMatch struct { - Value float64 `json:"value"` + Value null.Float `json:"value"` Metric string `json:"metric"` Tags map[string]string `json:"tags"` } diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 0775b48f2b0..7e213058cd0 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -13,6 +13,14 @@ import ( m "github.com/grafana/grafana/pkg/models" ) +type NotifierPlugin struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + OptionsTemplate string `json:"optionsTemplate"` + Factory NotifierFactory `json:"-"` +} + type RootNotifier struct { log log.Logger } @@ -130,12 +138,12 @@ func (n *RootNotifier) getNotifiers(orgId int64, notificationIds []int64, contex } func (n *RootNotifier) createNotifierFor(model *m.AlertNotification) (Notifier, error) { - factory, found := notifierFactories[model.Type] + notifierPlugin, found := notifierFactories[model.Type] if !found { return nil, errors.New("Unsupported notification type") } - return factory(model) + return notifierPlugin.Factory(model) } func shouldUseNotification(notifier Notifier, context *EvalContext) bool { @@ -152,8 +160,18 @@ func shouldUseNotification(notifier Notifier, context *EvalContext) bool { type NotifierFactory func(notification *m.AlertNotification) (Notifier, error) -var notifierFactories map[string]NotifierFactory = make(map[string]NotifierFactory) +var notifierFactories map[string]*NotifierPlugin = make(map[string]*NotifierPlugin) -func RegisterNotifier(typeName string, factory NotifierFactory) { - notifierFactories[typeName] = factory +func RegisterNotifier(plugin *NotifierPlugin) { + notifierFactories[plugin.Type] = plugin +} + +func GetNotifiers() []*NotifierPlugin { + list := make([]*NotifierPlugin, 0) + + for _, value := range notifierFactories { + list = append(list, value) + } + + return list } diff --git a/pkg/services/alerting/notifiers/email.go b/pkg/services/alerting/notifiers/email.go index 8cd4273e6be..4058d3860b5 100644 --- a/pkg/services/alerting/notifiers/email.go +++ b/pkg/services/alerting/notifiers/email.go @@ -13,7 +13,21 @@ import ( ) func init() { - alerting.RegisterNotifier("email", NewEmailNotifier) + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "email", + Name: "Email", + Description: "Sends notifications using Grafana server configured STMP settings", + Factory: NewEmailNotifier, + OptionsTemplate: ` +

Email addresses

+
+ +
+
+ You can enter multiple email addresses using a ";" separator +
+ `, + }) } type EmailNotifier struct { diff --git a/pkg/services/alerting/notifiers/line.go b/pkg/services/alerting/notifiers/line.go new file mode 100644 index 00000000000..0f544cbdfb6 --- /dev/null +++ b/pkg/services/alerting/notifiers/line.go @@ -0,0 +1,94 @@ +package notifiers + +import ( + "fmt" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/metrics" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + "net/url" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "LINE", + Name: "LINE", + Description: "Send notifications to LINE notify", + Factory: NewLINENotifier, + OptionsTemplate: ` +
+

LINE notify settings

+
+ Token + +
+
+`, + }) +} + +const ( + lineNotifyUrl string = "https://notify-api.line.me/api/notify" +) + +func NewLINENotifier(model *m.AlertNotification) (alerting.Notifier, error) { + token := model.Settings.Get("token").MustString() + if token == "" { + return nil, alerting.ValidationError{Reason: "Could not find token in settings"} + } + + return &LineNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + Token: token, + log: log.New("alerting.notifier.line"), + }, nil +} + +type LineNotifier struct { + NotifierBase + Token string + log log.Logger +} + +func (this *LineNotifier) Notify(evalContext *alerting.EvalContext) error { + this.log.Info("Executing line notification", "ruleId", evalContext.Rule.Id, "notification", this.Name) + metrics.M_Alerting_Notification_Sent_LINE.Inc(1) + + var err error + switch evalContext.Rule.State { + case m.AlertStateAlerting: + err = this.createAlert(evalContext) + } + return err +} + +func (this *LineNotifier) createAlert(evalContext *alerting.EvalContext) error { + this.log.Info("Creating Line notify", "ruleId", evalContext.Rule.Id, "notification", this.Name) + ruleUrl, err := evalContext.GetRuleUrl() + if err != nil { + this.log.Error("Failed get rule link", "error", err) + return err + } + + form := url.Values{} + body := fmt.Sprintf("%s - %s\n%s", evalContext.Rule.Name, ruleUrl, evalContext.Rule.Message) + form.Add("message", body) + + cmd := &m.SendWebhookSync{ + Url: lineNotifyUrl, + HttpMethod: "POST", + HttpHeader: map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", this.Token), + "Content-Type": "application/x-www-form-urlencoded", + }, + Body: form.Encode(), + } + + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + this.log.Error("Failed to send notification to LINE", "error", err, "body", string(body)) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/line_test.go b/pkg/services/alerting/notifiers/line_test.go new file mode 100644 index 00000000000..6630665e992 --- /dev/null +++ b/pkg/services/alerting/notifiers/line_test.go @@ -0,0 +1,49 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestLineNotifier(t *testing.T) { + Convey("Line notifier tests", t, func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "line_testing", + Type: "line", + Settings: settingsJSON, + } + + _, err := NewLINENotifier(model) + So(err, ShouldNotBeNil) + + }) + Convey("settings should trigger incident", func() { + json := ` + { + "token": "abcdefgh0123456789" + }` + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "line_testing", + Type: "line", + Settings: settingsJSON, + } + + not, err := NewLINENotifier(model) + lineNotifier := not.(*LineNotifier) + + So(err, ShouldBeNil) + So(lineNotifier.Name, ShouldEqual, "line_testing") + So(lineNotifier.Type, ShouldEqual, "line") + So(lineNotifier.Token, ShouldEqual, "abcdefgh0123456789") + }) + + }) +} diff --git a/pkg/services/alerting/notifiers/opsgenie.go b/pkg/services/alerting/notifiers/opsgenie.go index a93b650d494..742aeb922b6 100644 --- a/pkg/services/alerting/notifiers/opsgenie.go +++ b/pkg/services/alerting/notifiers/opsgenie.go @@ -13,7 +13,28 @@ import ( ) func init() { - alerting.RegisterNotifier("opsgenie", NewOpsGenieNotifier) + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "opsgenie", + Name: "OpsGenie", + Description: "Sends notifications to OpsGenie", + Factory: NewOpsGenieNotifier, + OptionsTemplate: ` +

OpsGenie settings

+
+ API Key + +
+
+ + +
+ `, + }) } var ( diff --git a/pkg/services/alerting/notifiers/pagerduty.go b/pkg/services/alerting/notifiers/pagerduty.go index e94bd5ebeaa..0c98ab00e20 100644 --- a/pkg/services/alerting/notifiers/pagerduty.go +++ b/pkg/services/alerting/notifiers/pagerduty.go @@ -12,7 +12,28 @@ import ( ) func init() { - alerting.RegisterNotifier("pagerduty", NewPagerdutyNotifier) + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "pagerduty", + Name: "PagerDuty", + Description: "Sends notifications to PagerDuty", + Factory: NewPagerdutyNotifier, + OptionsTemplate: ` +

PagerDuty settings

+
+ Integration Key + +
+
+ + +
+ `, + }) } var ( diff --git a/pkg/services/alerting/notifiers/sensu.go b/pkg/services/alerting/notifiers/sensu.go new file mode 100644 index 00000000000..dbe31f4cf84 --- /dev/null +++ b/pkg/services/alerting/notifiers/sensu.go @@ -0,0 +1,115 @@ +package notifiers + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/metrics" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + "strconv" + "strings" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "sensu", + Name: "Sensu", + Description: "Sends HTTP POST request to a Sensu API", + Factory: NewSensuNotifier, + OptionsTemplate: ` +

Sensu settings

+
+ Url + +
+
+ Username + +
+
+ Password + +
+ `, + }) + +} + +func NewSensuNotifier(model *m.AlertNotification) (alerting.Notifier, error) { + url := model.Settings.Get("url").MustString() + if url == "" { + return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} + } + + return &SensuNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + Url: url, + User: model.Settings.Get("username").MustString(), + Password: model.Settings.Get("password").MustString(), + log: log.New("alerting.notifier.sensu"), + }, nil +} + +type SensuNotifier struct { + NotifierBase + Url string + User string + Password string + log log.Logger +} + +func (this *SensuNotifier) Notify(evalContext *alerting.EvalContext) error { + this.log.Info("Sending sensu result") + metrics.M_Alerting_Notification_Sent_Sensu.Inc(1) + + bodyJSON := simplejson.New() + bodyJSON.Set("ruleId", evalContext.Rule.Id) + // Sensu alerts cannot have spaces in them + bodyJSON.Set("name", strings.Replace(evalContext.Rule.Name, " ", "_", -1)) + // Sensu alerts require a command + // We set it to the grafana ruleID + bodyJSON.Set("source", "grafana_rule_"+strconv.FormatInt(evalContext.Rule.Id, 10)) + // Finally, sensu expects an output + // We set it to a default output + bodyJSON.Set("output", "Grafana Metric Condition Met") + bodyJSON.Set("evalMatches", evalContext.EvalMatches) + + if evalContext.Rule.State == "alerting" { + bodyJSON.Set("status", 2) + } else if evalContext.Rule.State == "no_data" { + bodyJSON.Set("status", 1) + } else { + bodyJSON.Set("status", 0) + } + + ruleUrl, err := evalContext.GetRuleUrl() + if err == nil { + bodyJSON.Set("ruleUrl", ruleUrl) + } + + if evalContext.ImagePublicUrl != "" { + bodyJSON.Set("imageUrl", evalContext.ImagePublicUrl) + } + + if evalContext.Rule.Message != "" { + bodyJSON.Set("message", evalContext.Rule.Message) + } + + body, _ := bodyJSON.MarshalJSON() + + cmd := &m.SendWebhookSync{ + Url: this.Url, + User: this.User, + Password: this.Password, + Body: string(body), + HttpMethod: "POST", + } + + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + this.log.Error("Failed to send sensu event", "error", err, "sensu", this.Name) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/sensu_test.go b/pkg/services/alerting/notifiers/sensu_test.go new file mode 100644 index 00000000000..ffbdcfaf15c --- /dev/null +++ b/pkg/services/alerting/notifiers/sensu_test.go @@ -0,0 +1,52 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestSensuNotifier(t *testing.T) { + Convey("Sensu notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "sensu", + Type: "sensu", + Settings: settingsJSON, + } + + _, err := NewSensuNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("from settings", func() { + json := ` + { + "url": "http://sensu-api.example.com:4567/results" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "sensu", + Type: "sensu", + Settings: settingsJSON, + } + + not, err := NewSensuNotifier(model) + sensuNotifier := not.(*SensuNotifier) + + So(err, ShouldBeNil) + So(sensuNotifier.Name, ShouldEqual, "sensu") + So(sensuNotifier.Type, ShouldEqual, "sensu") + So(sensuNotifier.Url, ShouldEqual, "http://sensu-api.example.com:4567/results") + }) + }) + }) +} diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index 2666662e32c..7b2bfd09c9a 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -13,7 +13,42 @@ import ( ) func init() { - alerting.RegisterNotifier("slack", NewSlackNotifier) + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "slack", + Name: "Slack", + Description: "Sends notifications using Grafana server configured STMP settings", + Factory: NewSlackNotifier, + OptionsTemplate: ` +

Slack settings

+
+ Url + +
+
+ Recipient + + + + Override default channel or user, use #channel-name or @username + +
+
+ Mention + + + + Mention a user or a group using @ when notifying in a channel + +
+ `, + }) + } func NewSlackNotifier(model *m.AlertNotification) (alerting.Notifier, error) { diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go new file mode 100644 index 00000000000..0c3e3bd3c61 --- /dev/null +++ b/pkg/services/alerting/notifiers/telegram.go @@ -0,0 +1,113 @@ +package notifiers + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/metrics" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +var ( + telegeramApiUrl string = "https://api.telegram.org/bot%s/%s" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "telegram", + Name: "Telegram", + Description: "Sends notifications to Telegram", + Factory: NewTelegramNotifier, + OptionsTemplate: ` +

Telegram API settings

+
+ BOT API Token + +
+
+ Chat ID + + + + Integer Telegram Chat Identifier + +
+ `, + }) + +} + +type TelegramNotifier struct { + NotifierBase + BotToken string + ChatID string + log log.Logger +} + +func NewTelegramNotifier(model *m.AlertNotification) (alerting.Notifier, error) { + if model.Settings == nil { + return nil, alerting.ValidationError{Reason: "No Settings Supplied"} + } + + botToken := model.Settings.Get("bottoken").MustString() + chatId := model.Settings.Get("chatid").MustString() + + if botToken == "" { + return nil, alerting.ValidationError{Reason: "Could not find Bot Token in settings"} + } + + if chatId == "" { + return nil, alerting.ValidationError{Reason: "Could not find Chat Id in settings"} + } + + return &TelegramNotifier{ + NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + BotToken: botToken, + ChatID: chatId, + log: log.New("alerting.notifier.telegram"), + }, nil +} + +func (this *TelegramNotifier) Notify(evalContext *alerting.EvalContext) error { + this.log.Info("Sending alert notification to", "bot_token", this.BotToken) + this.log.Info("Sending alert notification to", "chat_id", this.ChatID) + metrics.M_Alerting_Notification_Sent_Telegram.Inc(1) + + bodyJSON := simplejson.New() + + bodyJSON.Set("chat_id", this.ChatID) + bodyJSON.Set("parse_mode", "html") + + message := fmt.Sprintf("%s\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message) + + ruleUrl, err := evalContext.GetRuleUrl() + if err == nil { + message = message + fmt.Sprintf("URL: %s\n", ruleUrl) + } + bodyJSON.Set("text", message) + + url := fmt.Sprintf(telegeramApiUrl, this.BotToken, "sendMessage") + body, _ := bodyJSON.MarshalJSON() + + cmd := &m.SendWebhookSync{ + Url: url, + Body: string(body), + HttpMethod: "POST", + } + + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + this.log.Error("Failed to send webhook", "error", err, "webhook", this.Name) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/telegram_test.go b/pkg/services/alerting/notifiers/telegram_test.go new file mode 100644 index 00000000000..3e8066e273b --- /dev/null +++ b/pkg/services/alerting/notifiers/telegram_test.go @@ -0,0 +1,55 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestTelegramNotifier(t *testing.T) { + Convey("Telegram notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "telegram_testing", + Type: "telegram", + Settings: settingsJSON, + } + + _, err := NewTelegramNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("settings should trigger incident", func() { + json := ` + { + "bottoken": "abcdefgh0123456789", + "chatid": "-1234567890" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "telegram_testing", + Type: "telegram", + Settings: settingsJSON, + } + + not, err := NewTelegramNotifier(model) + telegramNotifier := not.(*TelegramNotifier) + + So(err, ShouldBeNil) + So(telegramNotifier.Name, ShouldEqual, "telegram_testing") + So(telegramNotifier.Type, ShouldEqual, "telegram") + So(telegramNotifier.BotToken, ShouldEqual, "abcdefgh0123456789") + So(telegramNotifier.ChatID, ShouldEqual, "-1234567890") + }) + + }) + }) +} diff --git a/pkg/services/alerting/notifiers/victorops.go b/pkg/services/alerting/notifiers/victorops.go index 9afa1efb281..a4e34a40b8a 100644 --- a/pkg/services/alerting/notifiers/victorops.go +++ b/pkg/services/alerting/notifiers/victorops.go @@ -16,7 +16,19 @@ import ( const AlertStateCritical = "CRITICAL" func init() { - alerting.RegisterNotifier("victorops", NewVictoropsNotifier) + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "victorops", + Name: "VictorOps", + Description: "Sends notifications to VictorOps", + Factory: NewVictoropsNotifier, + OptionsTemplate: ` +

VictorOps settings

+
+ Url + +
+ `, + }) } // NewVictoropsNotifier creates an instance of VictoropsNotifier that diff --git a/pkg/services/alerting/notifiers/webhook.go b/pkg/services/alerting/notifiers/webhook.go index 0603cf45084..87868d331b2 100644 --- a/pkg/services/alerting/notifiers/webhook.go +++ b/pkg/services/alerting/notifiers/webhook.go @@ -10,7 +10,35 @@ import ( ) func init() { - alerting.RegisterNotifier("webhook", NewWebHookNotifier) + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "webhook", + Name: "webhook", + Description: "Sends HTTP POST request to a URL", + Factory: NewWebHookNotifier, + OptionsTemplate: ` +

Webhook settings

+
+ Url + +
+
+ Http Method +
+ +
+
+
+ Username + +
+
+ Password + +
+ `, + }) + } func NewWebHookNotifier(model *m.AlertNotification) (alerting.Notifier, error) { @@ -22,7 +50,7 @@ func NewWebHookNotifier(model *m.AlertNotification) (alerting.Notifier, error) { return &WebhookNotifier{ NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), Url: url, - User: model.Settings.Get("user").MustString(), + User: model.Settings.Get("username").MustString(), Password: model.Settings.Get("password").MustString(), HttpMethod: model.Settings.Get("httpMethod").MustString("POST"), log: log.New("alerting.notifier.webhook"), diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index aab109088cb..6b31dd4f951 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -27,50 +27,21 @@ func NewResultHandler() *DefaultResultHandler { } } -func (handler *DefaultResultHandler) GetStateFromEvaluation(evalContext *EvalContext) m.AlertStateType { - if evalContext.Error != nil { - handler.log.Error("Alert Rule Result Error", - "ruleId", evalContext.Rule.Id, - "name", evalContext.Rule.Name, - "error", evalContext.Error, - "changing state to", evalContext.Rule.ExecutionErrorState.ToAlertState()) - - if evalContext.Rule.ExecutionErrorState == m.ExecutionErrorKeepState { - return evalContext.PrevAlertState - } else { - return evalContext.Rule.ExecutionErrorState.ToAlertState() - } - } else if evalContext.Firing { - return m.AlertStateAlerting - } else if evalContext.NoDataFound { - handler.log.Info("Alert Rule returned no data", - "ruleId", evalContext.Rule.Id, - "name", evalContext.Rule.Name, - "changing state to", evalContext.Rule.NoDataState.ToAlertState()) - - if evalContext.Rule.NoDataState == m.NoDataKeepState { - return evalContext.PrevAlertState - } else { - return evalContext.Rule.NoDataState.ToAlertState() - } - } - - return m.AlertStateOK -} - func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error { executionError := "" annotationData := simplejson.New() - evalContext.Rule.State = handler.GetStateFromEvaluation(evalContext) + if evalContext.Firing { + annotationData = simplejson.NewFromAny(evalContext.EvalMatches) + } if evalContext.Error != nil { executionError = evalContext.Error.Error() annotationData.Set("errorMessage", executionError) } - if evalContext.Firing { - annotationData = simplejson.NewFromAny(evalContext.EvalMatches) + if evalContext.NoDataFound { + annotationData.Set("no_data", true) } countStateResult(evalContext.Rule.State) diff --git a/pkg/services/alerting/result_handler_test.go b/pkg/services/alerting/result_handler_test.go deleted file mode 100644 index 321838d4f4c..00000000000 --- a/pkg/services/alerting/result_handler_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package alerting - -import ( - "context" - "testing" - - "fmt" - - "github.com/grafana/grafana/pkg/models" - . "github.com/smartystreets/goconvey/convey" -) - -func TestAlertingResultHandler(t *testing.T) { - Convey("Result handler", t, func() { - ctx := NewEvalContext(context.TODO(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}) - dummieError := fmt.Errorf("dummie") - handler := NewResultHandler() - - Convey("Should update alert state", func() { - - Convey("ok -> alerting", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Firing = true - - So(handler.GetStateFromEvaluation(ctx), ShouldEqual, models.AlertStateAlerting) - So(ctx.ShouldUpdateAlertState(), ShouldBeTrue) - }) - - Convey("ok -> error(alerting)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Error = dummieError - ctx.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting - - ctx.Rule.State = handler.GetStateFromEvaluation(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) - So(ctx.ShouldUpdateAlertState(), ShouldBeTrue) - }) - - Convey("ok -> error(keep_last)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Error = dummieError - ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState - - ctx.Rule.State = handler.GetStateFromEvaluation(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) - So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) - }) - - Convey("pending -> error(keep_last)", func() { - ctx.PrevAlertState = models.AlertStatePending - ctx.Error = dummieError - ctx.Rule.ExecutionErrorState = models.ExecutionErrorKeepState - - ctx.Rule.State = handler.GetStateFromEvaluation(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) - So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) - }) - - Convey("ok -> no_data(alerting)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Rule.NoDataState = models.NoDataSetAlerting - ctx.NoDataFound = true - - ctx.Rule.State = handler.GetStateFromEvaluation(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStateAlerting) - So(ctx.ShouldUpdateAlertState(), ShouldBeTrue) - }) - - Convey("ok -> no_data(keep_last)", func() { - ctx.PrevAlertState = models.AlertStateOK - ctx.Rule.NoDataState = models.NoDataKeepState - ctx.NoDataFound = true - - ctx.Rule.State = handler.GetStateFromEvaluation(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStateOK) - So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) - }) - - Convey("pending -> no_data(keep_last)", func() { - ctx.PrevAlertState = models.AlertStatePending - ctx.Rule.NoDataState = models.NoDataKeepState - ctx.NoDataFound = true - - ctx.Rule.State = handler.GetStateFromEvaluation(ctx) - So(ctx.Rule.State, ShouldEqual, models.AlertStatePending) - So(ctx.ShouldUpdateAlertState(), ShouldBeFalse) - }) - }) - }) -} diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go index fd908d6f95d..a40d0ac3c5c 100644 --- a/pkg/services/alerting/test_notification.go +++ b/pkg/services/alerting/test_notification.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" @@ -63,12 +64,12 @@ func evalMatchesBasedOnState() []*EvalMatch { matches := make([]*EvalMatch, 0) matches = append(matches, &EvalMatch{ Metric: "High value", - Value: 100, + Value: null.FloatFrom(100), }) matches = append(matches, &EvalMatch{ Metric: "Higher Value", - Value: 200, + Value: null.FloatFrom(200), }) return matches diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 97bf75fdcb9..1ca4df8877a 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -101,6 +101,7 @@ func createDialer() (*gomail.Dialer, error) { d := gomail.NewDialer(host, iPort, setting.Smtp.User, setting.Smtp.Password) d.TLSConfig = tlsconfig + d.LocalName = setting.InstanceName return d, nil } diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index c1ed9ac9e74..095ce15ef32 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -65,6 +65,7 @@ func SendWebhookSync(ctx context.Context, cmd *m.SendWebhookSync) error { Password: cmd.Password, Body: cmd.Body, HttpMethod: cmd.HttpMethod, + HttpHeader: cmd.HttpHeader, }) } diff --git a/pkg/services/notifications/webhook.go b/pkg/services/notifications/webhook.go index ac46a43f294..cf39af10478 100644 --- a/pkg/services/notifications/webhook.go +++ b/pkg/services/notifications/webhook.go @@ -19,6 +19,7 @@ type Webhook struct { Password string Body string HttpMethod string + HttpHeader map[string]string } var ( @@ -63,6 +64,10 @@ func sendWebRequestSync(ctx context.Context, webhook *Webhook) error { request.Header.Add("Authorization", util.GetBasicAuthHeader(webhook.User, webhook.Password)) } + for k, v := range webhook.HttpHeader { + request.Header.Set(k, v) + } + resp, err := ctxhttp.Do(ctx, http.DefaultClient, request) if err != nil { return err diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index 8718b03e786..66f3b5b7250 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -113,6 +113,12 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error { return err } + for i := range alerts { + if alerts[i].ExecutionError == " " { + alerts[i].ExecutionError = "" + } + } + query.Result = alerts return nil } diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index b6ddb5e6670..919bb6fd026 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -133,10 +133,16 @@ func UpdateOrg(cmd *m.UpdateOrgCommand) error { Updated: time.Now(), } - if _, err := sess.Id(cmd.OrgId).Update(&org); err != nil { + affectedRows, err := sess.Id(cmd.OrgId).Update(&org) + + if err != nil { return err } + if affectedRows == 0 { + return m.ErrOrgNotFound + } + sess.publishAfterCommit(&events.OrgUpdated{ Timestamp: org.Updated, Id: org.Id, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 0de7b87b164..9c2ba6ee13c 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -14,9 +14,10 @@ import ( "runtime" "strings" - "github.com/go-macaron/session" "gopkg.in/ini.v1" + "github.com/go-macaron/session" + "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/util" ) @@ -65,6 +66,7 @@ var ( SshPort int CertFile, KeyFile string RouterLogging bool + DataProxyLogging bool StaticRootPath string EnableGzip bool EnforceDomain bool @@ -148,7 +150,8 @@ var ( Quota QuotaSettings // Alerting - ExecuteAlerts bool + AlertingEnabled bool + ExecuteAlerts bool // logger logger log.Logger @@ -490,6 +493,7 @@ func NewConfigContext(args *CommandLineArgs) error { HttpAddr = server.Key("http_addr").MustString(DEFAULT_HTTP_ADDR) HttpPort = server.Key("http_port").MustString("3000") RouterLogging = server.Key("router_logging").MustBool(false) + EnableGzip = server.Key("enable_gzip").MustBool(false) EnforceDomain = server.Key("enforce_domain").MustBool(false) StaticRootPath = makeAbsolute(server.Key("static_root_path").String(), HomePath) @@ -498,6 +502,10 @@ func NewConfigContext(args *CommandLineArgs) error { return err } + // read data proxy settings + dataproxy := Cfg.Section("dataproxy") + DataProxyLogging = dataproxy.Key("logging").MustBool(false) + // read security settings security := Cfg.Section("security") SecretKey = security.Key("secret_key").String() @@ -571,6 +579,7 @@ func NewConfigContext(args *CommandLineArgs) error { LdapAllowSignup = ldapSec.Key("allow_sign_up").MustBool(true) alerting := Cfg.Section("alerting") + AlertingEnabled = alerting.Key("enabled").MustBool(true) ExecuteAlerts = alerting.Key("execute_alerts").MustBool(true) readSessionConfig() diff --git a/pkg/tsdb/influxdb/query.go b/pkg/tsdb/influxdb/query.go index 094f98cbe53..50d155131fd 100644 --- a/pkg/tsdb/influxdb/query.go +++ b/pkg/tsdb/influxdb/query.go @@ -2,7 +2,9 @@ package influxdb import ( "fmt" + "strconv" "strings" + "time" "regexp" @@ -15,24 +17,53 @@ var ( ) func (query *Query) Build(queryContext *tsdb.QueryContext) (string, error) { + var res string + if query.UseRawQuery && query.RawQuery != "" { - q := query.RawQuery - - q = strings.Replace(q, "$timeFilter", query.renderTimeFilter(queryContext), 1) - q = strings.Replace(q, "$interval", tsdb.CalculateInterval(queryContext.TimeRange), 1) - - return q, nil + res = query.RawQuery + } else { + res = query.renderSelectors(queryContext) + res += query.renderMeasurement() + res += query.renderWhereClause() + res += query.renderTimeFilter(queryContext) + res += query.renderGroupBy(queryContext) } - res := query.renderSelectors(queryContext) - res += query.renderMeasurement() - res += query.renderWhereClause() - res += query.renderTimeFilter(queryContext) - res += query.renderGroupBy(queryContext) + interval, err := getDefinedInterval(query, queryContext) + if err != nil { + return "", err + } + res = strings.Replace(res, "$timeFilter", query.renderTimeFilter(queryContext), 1) + res = strings.Replace(res, "$interval", interval.Text, 1) + res = strings.Replace(res, "$__interval_ms", strconv.FormatInt(interval.Value.Nanoseconds()/int64(time.Millisecond), 10), 1) + res = strings.Replace(res, "$__interval", interval.Text, 1) return res, nil } +func getDefinedInterval(query *Query, queryContext *tsdb.QueryContext) (*tsdb.Interval, error) { + defaultInterval := tsdb.CalculateInterval(queryContext.TimeRange) + + if query.Interval == "" { + return &defaultInterval, nil + } + + setInterval := strings.Replace(strings.Replace(query.Interval, "<", "", 1), ">", "", 1) + parsedSetInterval, err := time.ParseDuration(setInterval) + + if err != nil { + return nil, err + } + + if strings.Contains(query.Interval, ">") { + if defaultInterval.Value > parsedSetInterval { + return &defaultInterval, nil + } + } + + return &tsdb.Interval{Value: parsedSetInterval, Text: setInterval}, nil +} + func (query *Query) renderTags() []string { var res []string for i, tag := range query.Tags { diff --git a/pkg/tsdb/influxdb/query_part.go b/pkg/tsdb/influxdb/query_part.go index d634bc5c817..3145ff1b333 100644 --- a/pkg/tsdb/influxdb/query_part.go +++ b/pkg/tsdb/influxdb/query_part.go @@ -3,7 +3,6 @@ package influxdb import ( "fmt" "strings" - "time" "github.com/grafana/grafana/pkg/tsdb" ) @@ -93,30 +92,10 @@ func fieldRenderer(query *Query, queryContext *tsdb.QueryContext, part *QueryPar return fmt.Sprintf(`"%s"`, part.Params[0]) } -func getDefinedInterval(query *Query, queryContext *tsdb.QueryContext) string { - setInterval := strings.Replace(strings.Replace(query.Interval, "<", "", 1), ">", "", 1) - defaultInterval := tsdb.CalculateInterval(queryContext.TimeRange) - - if strings.Contains(query.Interval, ">") { - parsedDefaultInterval, err := time.ParseDuration(defaultInterval) - parsedSetInterval, err2 := time.ParseDuration(setInterval) - - if err == nil && err2 == nil && parsedDefaultInterval > parsedSetInterval { - return defaultInterval - } - } - - return setInterval -} - func functionRenderer(query *Query, queryContext *tsdb.QueryContext, part *QueryPart, innerExpr string) string { for i, param := range part.Params { - if param == "$interval" { - if query.Interval != "" { - part.Params[i] = getDefinedInterval(query, queryContext) - } else { - part.Params[i] = tsdb.CalculateInterval(queryContext.TimeRange) - } + if part.Type == "time" && param == "auto" { + part.Params[i] = "$__interval" } } diff --git a/pkg/tsdb/influxdb/query_part_test.go b/pkg/tsdb/influxdb/query_part_test.go index 456a7fb8d59..b5bae2fdf37 100644 --- a/pkg/tsdb/influxdb/query_part_test.go +++ b/pkg/tsdb/influxdb/query_part_test.go @@ -37,33 +37,20 @@ func TestInfluxdbQueryPart(t *testing.T) { So(res, ShouldEqual, "bottom(value, 3)") }) - Convey("render time", func() { + Convey("render time with $interval", func() { part, err := NewQueryPart("time", []string{"$interval"}) So(err, ShouldBeNil) res := part.Render(query, queryContext, "") - So(res, ShouldEqual, "time(200ms)") + So(res, ShouldEqual, "time($interval)") }) - Convey("render time interval >10s", func() { - part, err := NewQueryPart("time", []string{"$interval"}) + Convey("render time with auto", func() { + part, err := NewQueryPart("time", []string{"auto"}) So(err, ShouldBeNil) - query.Interval = ">10s" - res := part.Render(query, queryContext, "") - So(res, ShouldEqual, "time(10s)") - }) - - Convey("render time interval >1s and higher interval calculation", func() { - part, err := NewQueryPart("time", []string{"$interval"}) - queryContext := &tsdb.QueryContext{TimeRange: tsdb.NewTimeRange("1y", "now")} - So(err, ShouldBeNil) - - query.Interval = ">1s" - - res := part.Render(query, queryContext, "") - So(res, ShouldEqual, "time(168h)") + So(res, ShouldEqual, "time($__interval)") }) Convey("render spread", func() { diff --git a/pkg/tsdb/influxdb/query_test.go b/pkg/tsdb/influxdb/query_test.go index b6af67e5e42..533df27f91f 100644 --- a/pkg/tsdb/influxdb/query_test.go +++ b/pkg/tsdb/influxdb/query_test.go @@ -16,10 +16,15 @@ func TestInfluxdbQueryBuilder(t *testing.T) { qp1, _ := NewQueryPart("field", []string{"value"}) qp2, _ := NewQueryPart("mean", []string{}) - groupBy1, _ := NewQueryPart("time", []string{"$interval"}) + mathPartDivideBy100, _ := NewQueryPart("math", []string{"/ 100"}) + mathPartDivideByIntervalMs, _ := NewQueryPart("math", []string{"/ $__interval_ms"}) + + groupBy1, _ := NewQueryPart("time", []string{"$__interval"}) groupBy2, _ := NewQueryPart("tag", []string{"datacenter"}) groupBy3, _ := NewQueryPart("fill", []string{"null"}) + groupByOldInterval, _ := NewQueryPart("time", []string{"$interval"}) + tag1 := &Tag{Key: "hostname", Value: "server1", Operator: "="} tag2 := &Tag{Key: "hostname", Value: "server2", Operator: "=", Condition: "OR"} @@ -55,6 +60,43 @@ func TestInfluxdbQueryBuilder(t *testing.T) { So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "cpu" WHERE "hostname" = 'server1' OR "hostname" = 'server2' AND time > now() - 5m GROUP BY time(5s), "datacenter" fill(null)`) }) + Convey("can build query with math part", func() { + query := &Query{ + Selects: []*Select{{*qp1, *qp2, *mathPartDivideBy100}}, + Measurement: "cpu", + Interval: "5s", + } + + rawQuery, err := query.Build(queryContext) + So(err, ShouldBeNil) + So(rawQuery, ShouldEqual, `SELECT mean("value") / 100 FROM "cpu" WHERE time > now() - 5m`) + }) + + Convey("can build query with math part using $__interval_ms variable", func() { + query := &Query{ + Selects: []*Select{{*qp1, *qp2, *mathPartDivideByIntervalMs}}, + Measurement: "cpu", + Interval: "5s", + } + + rawQuery, err := query.Build(queryContext) + So(err, ShouldBeNil) + So(rawQuery, ShouldEqual, `SELECT mean("value") / 5000 FROM "cpu" WHERE time > now() - 5m`) + }) + + Convey("can build query with old $interval variable", func() { + query := &Query{ + Selects: []*Select{{*qp1, *qp2}}, + Measurement: "cpu", + Policy: "", + GroupBy: []*QueryPart{groupByOldInterval}, + } + + rawQuery, err := query.Build(queryContext) + So(err, ShouldBeNil) + So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "cpu" WHERE time > now() - 5m GROUP BY time(200ms)`) + }) + Convey("can render time range", func() { query := Query{} Convey("render from: 2h to now-1h", func() { @@ -86,43 +128,43 @@ func TestInfluxdbQueryBuilder(t *testing.T) { }) Convey("can render normal tags without operator", func() { - query := &Query{Tags: []*Tag{&Tag{Operator: "", Value: `value`, Key: "key"}}} + query := &Query{Tags: []*Tag{{Operator: "", Value: `value`, Key: "key"}}} So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = 'value'`) }) Convey("can render regex tags without operator", func() { - query := &Query{Tags: []*Tag{&Tag{Operator: "", Value: `/value/`, Key: "key"}}} + query := &Query{Tags: []*Tag{{Operator: "", Value: `/value/`, Key: "key"}}} So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" =~ /value/`) }) Convey("can render regex tags", func() { - query := &Query{Tags: []*Tag{&Tag{Operator: "=~", Value: `/value/`, Key: "key"}}} + query := &Query{Tags: []*Tag{{Operator: "=~", Value: `/value/`, Key: "key"}}} So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" =~ /value/`) }) Convey("can render number tags", func() { - query := &Query{Tags: []*Tag{&Tag{Operator: "=", Value: "10001", Key: "key"}}} + query := &Query{Tags: []*Tag{{Operator: "=", Value: "10001", Key: "key"}}} So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = '10001'`) }) Convey("can render numbers less then condition tags", func() { - query := &Query{Tags: []*Tag{&Tag{Operator: "<", Value: "10001", Key: "key"}}} + query := &Query{Tags: []*Tag{{Operator: "<", Value: "10001", Key: "key"}}} So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" < 10001`) }) Convey("can render number greather then condition tags", func() { - query := &Query{Tags: []*Tag{&Tag{Operator: ">", Value: "10001", Key: "key"}}} + query := &Query{Tags: []*Tag{{Operator: ">", Value: "10001", Key: "key"}}} So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" > 10001`) }) Convey("can render string tags", func() { - query := &Query{Tags: []*Tag{&Tag{Operator: "=", Value: "value", Key: "key"}}} + query := &Query{Tags: []*Tag{{Operator: "=", Value: "value", Key: "key"}}} So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = 'value'`) }) @@ -139,4 +181,5 @@ func TestInfluxdbQueryBuilder(t *testing.T) { So(query.renderMeasurement(), ShouldEqual, ` FROM "policy"./apa/`) }) }) + } diff --git a/pkg/tsdb/influxdb/response_parser.go b/pkg/tsdb/influxdb/response_parser.go index 8e9fa0022c3..b7db6182241 100644 --- a/pkg/tsdb/influxdb/response_parser.go +++ b/pkg/tsdb/influxdb/response_parser.go @@ -7,8 +7,8 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/tsdb" - "gopkg.in/guregu/null.v3" ) type ResponseParser struct{} diff --git a/pkg/tsdb/influxdb/response_parser_test.go b/pkg/tsdb/influxdb/response_parser_test.go index 67667f565d5..a517cf4d71f 100644 --- a/pkg/tsdb/influxdb/response_parser_test.go +++ b/pkg/tsdb/influxdb/response_parser_test.go @@ -19,7 +19,7 @@ func TestInfluxdbResponseParser(t *testing.T) { response := &Response{ Results: []Result{ - Result{ + { Series: []Row{ { Name: "cpu", @@ -69,7 +69,7 @@ func TestInfluxdbResponseParser(t *testing.T) { response := &Response{ Results: []Result{ - Result{ + { Series: []Row{ { Name: "cpu.upc", diff --git a/pkg/tsdb/interval.go b/pkg/tsdb/interval.go index 71caf122c13..aef6cc4f47b 100644 --- a/pkg/tsdb/interval.go +++ b/pkg/tsdb/interval.go @@ -12,14 +12,19 @@ var ( day time.Duration = time.Hour * 24 * 365 ) -func CalculateInterval(timerange *TimeRange) string { +type Interval struct { + Text string + Value time.Duration +} + +func CalculateInterval(timerange *TimeRange) Interval { interval := time.Duration((timerange.MustGetTo().UnixNano() - timerange.MustGetFrom().UnixNano()) / defaultRes) if interval < minInterval { - return formatDuration(minInterval) + return Interval{Text: formatDuration(minInterval), Value: interval} } - return formatDuration(roundInterval(interval)) + return Interval{Text: formatDuration(roundInterval(interval)), Value: interval} } func formatDuration(inter time.Duration) string { diff --git a/pkg/tsdb/interval_test.go b/pkg/tsdb/interval_test.go index c06e1879668..7b243b4e3ba 100644 --- a/pkg/tsdb/interval_test.go +++ b/pkg/tsdb/interval_test.go @@ -18,28 +18,28 @@ func TestInterval(t *testing.T) { tr := NewTimeRange("5m", "now") interval := CalculateInterval(tr) - So(interval, ShouldEqual, "200ms") + So(interval.Text, ShouldEqual, "200ms") }) Convey("for 15min", func() { tr := NewTimeRange("15m", "now") interval := CalculateInterval(tr) - So(interval, ShouldEqual, "500ms") + So(interval.Text, ShouldEqual, "500ms") }) Convey("for 30min", func() { tr := NewTimeRange("30m", "now") interval := CalculateInterval(tr) - So(interval, ShouldEqual, "1s") + So(interval.Text, ShouldEqual, "1s") }) Convey("for 1h", func() { tr := NewTimeRange("1h", "now") interval := CalculateInterval(tr) - So(interval, ShouldEqual, "2s") + So(interval.Text, ShouldEqual, "2s") }) Convey("Round interval", func() { diff --git a/pkg/tsdb/models.go b/pkg/tsdb/models.go index e95713e7077..68f26a68d50 100644 --- a/pkg/tsdb/models.go +++ b/pkg/tsdb/models.go @@ -1,9 +1,9 @@ package tsdb import ( + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" - "gopkg.in/guregu/null.v3" ) type Query struct { diff --git a/pkg/tsdb/mqe/response_parser.go b/pkg/tsdb/mqe/response_parser.go index 283098e74f3..d40106fc197 100644 --- a/pkg/tsdb/mqe/response_parser.go +++ b/pkg/tsdb/mqe/response_parser.go @@ -5,10 +5,9 @@ import ( "io/ioutil" "net/http" - null "gopkg.in/guregu/null.v3" - "fmt" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/tsdb" ) diff --git a/pkg/tsdb/mqe/types_test.go b/pkg/tsdb/mqe/types_test.go index 1138e0599e7..fccd6709bd4 100644 --- a/pkg/tsdb/mqe/types_test.go +++ b/pkg/tsdb/mqe/types_test.go @@ -27,16 +27,16 @@ func TestWildcardExpansion(t *testing.T) { Convey("Without wildcard series", func() { query := &Query{ Metrics: []Metric{ - Metric{Metric: "os.cpu.3.idle", Alias: ""}, - Metric{Metric: "os.cpu.2.idle", Alias: ""}, - Metric{Metric: "os.cpu.1.idle", Alias: "cpu"}, + {Metric: "os.cpu.3.idle", Alias: ""}, + {Metric: "os.cpu.2.idle", Alias: ""}, + {Metric: "os.cpu.1.idle", Alias: "cpu"}, }, Hosts: []string{"staples-lab-1", "staples-lab-2"}, Cluster: []string{"demoapp-1", "demoapp-2"}, AddClusterToAlias: false, AddHostToAlias: false, FunctionList: []Function{ - Function{Func: "aggregate.min"}, + {Func: "aggregate.min"}, }, TimeRange: &tsdb.TimeRange{Now: now, From: "5m", To: "now"}, } @@ -52,15 +52,15 @@ func TestWildcardExpansion(t *testing.T) { Convey("With two aggregate functions", func() { query := &Query{ Metrics: []Metric{ - Metric{Metric: "os.cpu.3.idle", Alias: ""}, + {Metric: "os.cpu.3.idle", Alias: ""}, }, Hosts: []string{"staples-lab-1", "staples-lab-2"}, Cluster: []string{"demoapp-1", "demoapp-2"}, AddClusterToAlias: false, AddHostToAlias: false, FunctionList: []Function{ - Function{Func: "aggregate.min"}, - Function{Func: "aggregate.max"}, + {Func: "aggregate.min"}, + {Func: "aggregate.max"}, }, TimeRange: &tsdb.TimeRange{Now: now, From: "5m", To: "now"}, } @@ -74,7 +74,7 @@ func TestWildcardExpansion(t *testing.T) { Convey("Containg wildcard series", func() { query := &Query{ Metrics: []Metric{ - Metric{Metric: "os.cpu*", Alias: ""}, + {Metric: "os.cpu*", Alias: ""}, }, Hosts: []string{"staples-lab-1"}, AddClusterToAlias: false, diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index f5b6ffda7d2..684f48bfc59 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -14,8 +14,7 @@ import ( "net/http" "net/url" - "gopkg.in/guregu/null.v3" - + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index c391f00920a..f3704c2a430 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -7,10 +7,9 @@ import ( "strings" "time" - "gopkg.in/guregu/null.v3" - "net/http" + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/tsdb" diff --git a/pkg/tsdb/testdata/scenarios.go b/pkg/tsdb/testdata/scenarios.go index 667b7fffcba..0c59973cd7b 100644 --- a/pkg/tsdb/testdata/scenarios.go +++ b/pkg/tsdb/testdata/scenarios.go @@ -6,8 +6,7 @@ import ( "strings" "time" - "gopkg.in/guregu/null.v3" - + "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/tsdb" ) diff --git a/public/app/app.ts b/public/app/app.ts index 22431a5110c..3603e793d5c 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -44,6 +44,8 @@ export class GrafanaApp { moment.locale(config.bootData.user.locale); app.config(($locationProvider, $controllerProvider, $compileProvider, $filterProvider, $httpProvider, $provide) => { + // pre assing bindings before constructor calls + $compileProvider.preAssignBindingsEnabled(true); if (config.buildInfo.env !== 'development') { $compileProvider.debugInfoEnabled(false); diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 83b55ac476f..f48d941a8ba 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -73,6 +73,9 @@ export function grafanaAppDirective(playlistSrv, contextSrv) { var ignoreSideMenuHide; var body = $('body'); + // see https://github.com/zenorocha/clipboard.js/issues/155 + $.fn.modal.Constructor.prototype.enforceFocus = function() {}; + // handle sidemenu open state scope.$watch('contextSrv.sidemenu', newVal => { if (newVal !== undefined) { diff --git a/public/app/core/components/jsontree/jsontree.ts b/public/app/core/components/jsontree/jsontree.ts index 14f6012ce12..5c39e2ab53c 100644 --- a/public/app/core/components/jsontree/jsontree.ts +++ b/public/app/core/components/jsontree/jsontree.ts @@ -117,6 +117,7 @@ coreModule.directive('jsonNode', ['ajsRecursiveDirectiveHelper', function jsonNo var isArray = utils.is(scope.value, 'Array'); scope.preview = isArray ? '[ ' : '{ '; utils.forKeys(scope.value, function jsonNodeDirectiveLinkForKeys(key, value) { + if (value === null) { scope.value[key] = 'null'; } if (isArray) { scope.preview += value + ', '; } else { diff --git a/public/app/core/components/query_part/query_part.ts b/public/app/core/components/query_part/query_part.ts index fcd337a0a26..cf6780ac182 100644 --- a/public/app/core/components/query_part/query_part.ts +++ b/public/app/core/components/query_part/query_part.ts @@ -89,7 +89,7 @@ export function functionRenderer(part, innerExpr) { var paramType = part.def.params[index]; if (paramType.type === 'time') { if (value === 'auto') { - value = '$interval'; + value = '$__interval'; } } if (paramType.quote === 'single') { diff --git a/public/app/core/components/sidemenu/sidemenu.ts b/public/app/core/components/sidemenu/sidemenu.ts index c6d721e0977..2e8e03eca7a 100644 --- a/public/app/core/components/sidemenu/sidemenu.ts +++ b/public/app/core/components/sidemenu/sidemenu.ts @@ -50,7 +50,7 @@ export class SideMenuCtrl { {text: 'Profile', url: this.getUrl('/profile')}, ]; - if (this.isSignedIn) { + if (this.showSignout) { this.orgMenu.push({text: "Sign out", url: this.getUrl("/logout"), target: "_self"}); } diff --git a/public/app/core/directives/value_select_dropdown.js b/public/app/core/directives/value_select_dropdown.js index 7f3c8ccc7bb..8b1719c669d 100644 --- a/public/app/core/directives/value_select_dropdown.js +++ b/public/app/core/directives/value_select_dropdown.js @@ -75,7 +75,7 @@ function (angular, _, coreModule) { tag.selected = !tag.selected; var tagValuesPromise; if (!tag.values) { - tagValuesPromise = vm.getValuesForTag({tagKey: tag.text}); + tagValuesPromise = vm.variable.getValuesForTag(tag.text); } else { tagValuesPromise = $q.when(tag.values); } @@ -225,7 +225,7 @@ function (angular, _, coreModule) { coreModule.default.directive('valueSelectDropdown', function($compile, $window, $timeout, $rootScope) { return { - scope: { variable: "=", onUpdated: "&", getValuesForTag: "&" }, + scope: { variable: "=", onUpdated: "&"}, templateUrl: 'public/app/partials/valueSelectDropdown.html', controller: 'ValueSelectDropdownCtrl', controllerAs: 'vm', diff --git a/public/app/core/services/alert_srv.ts b/public/app/core/services/alert_srv.ts index 286f03db5f5..ced6ffc6467 100644 --- a/public/app/core/services/alert_srv.ts +++ b/public/app/core/services/alert_srv.ts @@ -27,10 +27,9 @@ export class AlertSrv { this.set(alert[0], alert[1], 'success', 3000); }, this.$rootScope); - appEvents.on('alert-error', options => { - this.set(options[0], options[1], 'error', 7000); - }); - + appEvents.on('alert-warning', options => this.set(options[0], options[1], 'warning', 5000)); + appEvents.on('alert-success', options => this.set(options[0], options[1], 'success', 3000)); + appEvents.on('alert-error', options => this.set(options[0], options[1], 'error', 7000)); appEvents.on('confirm-modal', this.showConfirmModal.bind(this)); } diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index 3d048e4f869..f3d319f11c6 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -57,6 +57,10 @@ export class ContextSrv { store.set('grafana.sidemenu.pinned', val); } + isGrafanaVisible() { + return !!(document.visibilityState === undefined || document.visibilityState === 'visible'); + } + toggleSideMenu() { this.sidemenu = !this.sidemenu; if (!this.sidemenu) { diff --git a/public/app/core/services/timer.js b/public/app/core/services/timer.js index a41c7d1376f..67ad02367c0 100644 --- a/public/app/core/services/timer.js +++ b/public/app/core/services/timer.js @@ -22,7 +22,7 @@ function (angular, _, coreModule) { $timeout.cancel(promise); }; - this.cancel_all = function() { + this.cancelAll = function() { _.each(timers, function(t) { $timeout.cancel(t); }); diff --git a/public/app/core/time_series2.ts b/public/app/core/time_series2.ts index 4503be68710..4563a09cbca 100644 --- a/public/app/core/time_series2.ts +++ b/public/app/core/time_series2.ts @@ -104,6 +104,7 @@ export default class TimeSeries { this.stats.current = null; this.stats.first = null; this.stats.delta = 0; + this.stats.diff = null; this.stats.range = null; this.stats.timeStep = Number.MAX_VALUE; this.allIsNull = true; @@ -193,6 +194,9 @@ export default class TimeSeries { if (this.stats.max !== null && this.stats.min !== null) { this.stats.range = this.stats.max - this.stats.min; } + if (this.stats.current !== null && this.stats.first !== null) { + this.stats.diff = this.stats.current - this.stats.first; + } this.stats.count = result.length; return result; diff --git a/public/app/core/utils/file_export.ts b/public/app/core/utils/file_export.ts index 0366a76acc2..27f1064286f 100644 --- a/public/app/core/utils/file_export.ts +++ b/public/app/core/utils/file_export.ts @@ -50,7 +50,7 @@ export function exportSeriesListToCsvColumns(seriesList) { }; export function exportTableDataToCsv(table) { - var text = ''; + var text = 'sep=;\n'; // add header _.each(table.columns, function(column) { text += column.text + ';'; diff --git a/public/app/core/utils/kbn.js b/public/app/core/utils/kbn.js index 483b7849c19..182ab0e309a 100644 --- a/public/app/core/utils/kbn.js +++ b/public/app/core/utils/kbn.js @@ -399,6 +399,7 @@ function($, _) { kbn.valueFormats.currencyGBP = kbn.formatBuilders.currency('£'); kbn.valueFormats.currencyEUR = kbn.formatBuilders.currency('€'); kbn.valueFormats.currencyJPY = kbn.formatBuilders.currency('¥'); + kbn.valueFormats.currencyRUB = kbn.formatBuilders.currency('₽'); // Data (Binary) kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b'); @@ -440,12 +441,15 @@ function($, _) { kbn.valueFormats.voltamp = kbn.formatBuilders.decimalSIPrefix('VA'); kbn.valueFormats.kvoltamp = kbn.formatBuilders.decimalSIPrefix('VA', 1); kbn.valueFormats.voltampreact = kbn.formatBuilders.decimalSIPrefix('var'); + kbn.valueFormats.kvoltampreact = kbn.formatBuilders.decimalSIPrefix('var', 1); kbn.valueFormats.watth = kbn.formatBuilders.decimalSIPrefix('Wh'); kbn.valueFormats.kwatth = kbn.formatBuilders.decimalSIPrefix('Wh', 1); kbn.valueFormats.joule = kbn.formatBuilders.decimalSIPrefix('J'); kbn.valueFormats.ev = kbn.formatBuilders.decimalSIPrefix('eV'); kbn.valueFormats.amp = kbn.formatBuilders.decimalSIPrefix('A'); + kbn.valueFormats.kamp = kbn.formatBuilders.decimalSIPrefix('A', 1); kbn.valueFormats.volt = kbn.formatBuilders.decimalSIPrefix('V'); + kbn.valueFormats.kvolt = kbn.formatBuilders.decimalSIPrefix('V', 1); kbn.valueFormats.dBm = kbn.formatBuilders.decimalSIPrefix('dBm'); // Temperature @@ -701,6 +705,7 @@ function($, _) { {text: 'Pounds (£)', value: 'currencyGBP'}, {text: 'Euro (€)', value: 'currencyEUR'}, {text: 'Yen (¥)', value: 'currencyJPY'}, + {text: 'Rubles (₽)', value: 'currencyRUB'}, ] }, { @@ -798,12 +803,15 @@ function($, _) { {text: 'volt-ampere (VA)', value: 'voltamp' }, {text: 'kilovolt-ampere (kVA)', value: 'kvoltamp' }, {text: 'volt-ampere reactive (var)', value: 'voltampreact'}, + {text: 'kilovolt-ampere reactive (kvar)', value: 'kvoltampreact'}, {text: 'watt-hour (Wh)', value: 'watth' }, {text: 'kilowatt-hour (kWh)', value: 'kwatth' }, {text: 'joule (J)', value: 'joule' }, {text: 'electron volt (eV)', value: 'ev' }, {text: 'Ampere (A)', value: 'amp' }, + {text: 'Kiloampere (kA)', value: 'kamp' }, {text: 'Volt (V)', value: 'volt' }, + {text: 'Kilovolt (kV)', value: 'kvolt' }, {text: 'Decibel-milliwatt (dBm)', value: 'dBm' }, ] }, diff --git a/public/app/core/utils/model_utils.ts b/public/app/core/utils/model_utils.ts index 3040094b1e3..dd620aff53d 100644 --- a/public/app/core/utils/model_utils.ts +++ b/public/app/core/utils/model_utils.ts @@ -1,4 +1,4 @@ -export function assignModelProperties(target, source, defaults) { +export function assignModelProperties(target, source, defaults, removeDefaults?) { for (var key in defaults) { if (!defaults.hasOwnProperty(key)) { continue; diff --git a/public/app/core/utils/sort_by_keys.ts b/public/app/core/utils/sort_by_keys.ts new file mode 100644 index 00000000000..9dff252576a --- /dev/null +++ b/public/app/core/utils/sort_by_keys.ts @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +export default function sortByKeys(input) { + if (_.isArray(input)) { + return input.map(sortByKeys); + } + + if (_.isPlainObject(input)) { + var sortedObject = {}; + for (let key of _.keys(input).sort()) { + sortedObject[key] = sortByKeys(input[key]); + } + return sortedObject; + } + + return input; +} diff --git a/public/app/features/alerting/alert_def.ts b/public/app/features/alerting/alert_def.ts index e6bd75379b2..fab612b5558 100644 --- a/public/app/features/alerting/alert_def.ts +++ b/public/app/features/alerting/alert_def.ts @@ -116,14 +116,19 @@ function getStateDisplayModel(state) { } } -function joinEvalMatches(matches, seperator: string) { +function joinEvalMatches(matches, separator: string) { return _.reduce(matches, (res, ev)=> { + if (ev.metric !== undefined && ev.value !== undefined) { + res.push(ev.metric + '=' + ev.value); + } + + // For backwards compatibility . Should be be able to remove this after ~2017-06-01 if (ev.Metric !== undefined && ev.Value !== undefined) { - res.push(ev.Metric + "=" + ev.Value); + res.push(ev.Metric + '=' + ev.Value); } return res; - }, []).join(seperator); + }, []).join(separator); } export default { diff --git a/public/app/features/alerting/alert_list_ctrl.ts b/public/app/features/alerting/alert_list_ctrl.ts index 5e3cce35c6e..78713ee0c4c 100644 --- a/public/app/features/alerting/alert_list_ctrl.ts +++ b/public/app/features/alerting/alert_list_ctrl.ts @@ -37,6 +37,9 @@ export class AlertListCtrl { this.alerts = _.map(result, alert => { alert.stateModel = alertDef.getStateDisplayModel(alert.state); alert.newStateDateAgo = moment(alert.newStateDate).fromNow().replace(" ago", ""); + if (alert.evalData && alert.evalData.no_data) { + alert.no_data = true; + } return alert; }); }); diff --git a/public/app/features/alerting/alert_tab_ctrl.ts b/public/app/features/alerting/alert_tab_ctrl.ts index c6683556745..89b1c63372f 100644 --- a/public/app/features/alerting/alert_tab_ctrl.ts +++ b/public/app/features/alerting/alert_tab_ctrl.ts @@ -82,6 +82,15 @@ export class AlertTabCtrl { ah.time = moment(ah.time).format('MMM D, YYYY HH:mm:ss'); ah.stateModel = alertDef.getStateDisplayModel(ah.newState); ah.metrics = alertDef.joinEvalMatches(ah.data, ', '); + + if (ah.data.errorMessage) { + ah.metrics = "Error: " + ah.data.errorMessage; + } + + if (ah.data.no_data) { + ah.metrics = "(due to no data)"; + } + return ah; }); }); diff --git a/public/app/features/alerting/notification_edit_ctrl.ts b/public/app/features/alerting/notification_edit_ctrl.ts index 77e67d96d86..39c9d8ca468 100644 --- a/public/app/features/alerting/notification_edit_ctrl.ts +++ b/public/app/features/alerting/notification_edit_ctrl.ts @@ -2,40 +2,48 @@ import angular from 'angular'; import _ from 'lodash'; -import coreModule from '../../core/core_module'; import config from 'app/core/config'; +import {appEvents, coreModule} from 'app/core/core'; export class AlertNotificationEditCtrl { - model: any; theForm: any; - testSeverity: string = "critical"; + testSeverity = "critical"; + notifiers: any; + notifierTemplateId: string; + + model: any; + defaults: any = { + type: 'email', + settings: { + httpMethod: 'POST', + autoResolve: true, + }, + isDefault: false + }; /** @ngInject */ - constructor(private $routeParams, private backendSrv, private $scope, private $location) { - if ($routeParams.id) { - this.loadNotification($routeParams.id); - } else { - this.model = { - type: 'email', - settings: { - httpMethod: 'POST', - autoResolve: true, - }, - isDefault: false - }; - } - } + constructor(private $routeParams, private backendSrv, private $location, private $templateCache) { + this.backendSrv.get(`/api/alert-notifiers`).then(notifiers => { + this.notifiers = notifiers; - loadNotification(id) { - this.backendSrv.get(`/api/alert-notifications/${id}`).then(result => { - this.model = result; + // add option templates + for (let notifier of this.notifiers) { + this.$templateCache.put(this.getNotifierTemplateId(notifier.type), notifier.optionsTemplate); + } + + if (!this.$routeParams.id) { + return this.model; + } + + return this.backendSrv.get(`/api/alert-notifications/${this.$routeParams.id}`).then(result => { + return result; + }); + }).then(model => { + this.model = model; + this.notifierTemplateId = this.getNotifierTemplateId(this.model.type); }); } - isNew() { - return this.model.id === undefined; - } - save() { if (!this.theForm.$valid) { return; @@ -44,18 +52,23 @@ export class AlertNotificationEditCtrl { if (this.model.id) { this.backendSrv.put(`/api/alert-notifications/${this.model.id}`, this.model).then(res => { this.model = res; - this.$scope.appEvent('alert-success', ['Notification updated', '']); + appEvents.emit('alert-success', ['Notification updated', '']); }); } else { this.backendSrv.post(`/api/alert-notifications`, this.model).then(res => { - this.$scope.appEvent('alert-success', ['Notification created', '']); + appEvents.emit('alert-success', ['Notification created', '']); this.$location.path('alerting/notifications'); }); } } + getNotifierTemplateId(type) { + return `notifier-options-${type}`; + } + typeChanged() { this.model.settings = {}; + this.notifierTemplateId = this.getNotifierTemplateId(this.model.type); } testNotification() { @@ -70,9 +83,9 @@ export class AlertNotificationEditCtrl { }; this.backendSrv.post(`/api/alert-notifications/test`, payload) - .then(res => { - this.$scope.appEvent('alert-succes', ['Test notification sent', '']); - }); + .then(res => { + appEvents.emit('alert-succes', ['Test notification sent', '']); + }); } } diff --git a/public/app/features/alerting/partials/alert_list.html b/public/app/features/alerting/partials/alert_list.html index 35305b9bf03..5b723386904 100644 --- a/public/app/features/alerting/partials/alert_list.html +++ b/public/app/features/alerting/partials/alert_list.html @@ -52,11 +52,11 @@
- {{alert.stateModel.text}} + {{alert.stateModel.text}} (due to no data) for {{alert.newStateDateAgo}}
-
- {{alert.executionError}} +
+ Error: "{{alert.executionError}}"
@@ -64,6 +64,4 @@ - - diff --git a/public/app/features/alerting/partials/notification_edit.html b/public/app/features/alerting/partials/notification_edit.html index b5076efd792..dfdfb115f4b 100644 --- a/public/app/features/alerting/partials/notification_edit.html +++ b/public/app/features/alerting/partials/notification_edit.html @@ -1,16 +1,17 @@ - - Notifications + + Notification channels -
+
-
+
Name @@ -19,7 +20,7 @@
Type
-
@@ -34,109 +35,7 @@
-
-

Webhook settings

-
- Url - -
-
- Http Method -
- -
-
-
- Username - -
-
- Password - -
-
- -
-

Slack settings

-
- Url - -
-
- Recipient - - - - Override default channel or user, use #channel-name or @username - -
-
- Mention - - - - Mention a user or a group using @ when notifying in a channel - -
-
- -
-

VictorOps settings

-
- Url - -
-
- -
-

Email addresses

-
- -
-
- You can enter multiple email addresses using a ";" separator -
-
- -
-

Pagerduty settings

-
- Integration Key - -
-
- - -
-
- -
-

OpsGenie settings

-
- API Key - -
-
- - -
+
diff --git a/public/app/features/alerting/partials/notifications_list.html b/public/app/features/alerting/partials/notifications_list.html index 8777d6f6a1b..32ac4a44476 100644 --- a/public/app/features/alerting/partials/notifications_list.html +++ b/public/app/features/alerting/partials/notifications_list.html @@ -3,10 +3,10 @@
diff --git a/public/app/features/dashboard/all.js b/public/app/features/dashboard/all.js index 976bff07058..c362f9cd032 100644 --- a/public/app/features/dashboard/all.js +++ b/public/app/features/dashboard/all.js @@ -9,7 +9,7 @@ define([ './shareSnapshotCtrl', './dashboard_srv', './viewStateSrv', - './timeSrv', + './time_srv', './unsavedChangesSrv', './timepicker/timepicker', './graphiteImportCtrl', diff --git a/public/app/features/dashboard/export/exporter.ts b/public/app/features/dashboard/export/exporter.ts index 5efcd498f67..0a0b4cbdaab 100644 --- a/public/app/features/dashboard/export/exporter.ts +++ b/public/app/features/dashboard/export/exporter.ts @@ -145,18 +145,14 @@ export class DashboardExporter { } } - requires = _.map(requires, req => { - return req; - }); - // make inputs and requires a top thing var newObj = {}; newObj["__inputs"] = inputs; - newObj["__requires"] = requires; + newObj["__requires"] = _.sortBy(requires, ['id']); _.defaults(newObj, saveModel); - return newObj; + }).catch(err => { console.log('Export failed:', err); return { diff --git a/public/app/features/dashboard/model.ts b/public/app/features/dashboard/model.ts index cff82d1ffb0..e31a6c1afd0 100644 --- a/public/app/features/dashboard/model.ts +++ b/public/app/features/dashboard/model.ts @@ -8,6 +8,7 @@ import $ from 'jquery'; import {Emitter, contextSrv, appEvents} from 'app/core/core'; import {DashboardRow} from './row/row_model'; +import sortByKeys from 'app/core/utils/sort_by_keys'; export class DashboardModel { id: any; @@ -36,7 +37,7 @@ export class DashboardModel { events: any; editMode: boolean; - constructor(data, meta) { + constructor(data, meta?) { if (!data) { data = {}; } @@ -107,7 +108,10 @@ export class DashboardModel { this.rows = _.map(rows, row => row.getSaveModel()); this.templating.list = _.map(variables, variable => variable.getSaveModel ? variable.getSaveModel() : variable); + // make clone var copy = $.extend(true, {}, this); + // sort clone + copy = sortByKeys(copy); // restore properties this.events = events; diff --git a/public/app/features/dashboard/shareModalCtrl.js b/public/app/features/dashboard/shareModalCtrl.js index 77a6cfb21fc..54b09fbc6d2 100644 --- a/public/app/features/dashboard/shareModalCtrl.js +++ b/public/app/features/dashboard/shareModalCtrl.js @@ -1,9 +1,11 @@ define(['angular', 'lodash', + 'jquery', + 'moment', 'require', 'app/core/config', ], -function (angular, _, require, config) { +function (angular, _, $, moment, require, config) { 'use strict'; var module = angular.module('grafana.controllers'); @@ -82,17 +84,21 @@ function (angular, _, require, config) { $scope.imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/'); $scope.imageUrl += '&width=1000'; $scope.imageUrl += '&height=500'; + $scope.imageUrl += '&tz=UTC' + encodeURIComponent(moment().format("Z")); }; }); module.directive('clipboardButton',function() { return function(scope, elem) { - require(['vendor/zero_clipboard'], function(ZeroClipboard) { - ZeroClipboard.config({ - swfPath: config.appSubUrl + '/public/vendor/zero_clipboard.swf' - }); - new ZeroClipboard(elem[0]); + require(['vendor/clipboard/dist/clipboard'], function(Clipboard) { + scope.clipboard = new Clipboard(elem[0]); + }); + + scope.$on('$destroy', function() { + if (scope.clipboard) { + scope.clipboard.destroy(); + } }); }; }); diff --git a/public/app/features/dashboard/specs/dashboard_model_specs.ts b/public/app/features/dashboard/specs/dashboard_model_specs.ts new file mode 100644 index 00000000000..c7d85b8a190 --- /dev/null +++ b/public/app/features/dashboard/specs/dashboard_model_specs.ts @@ -0,0 +1,367 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import _ from 'lodash'; +import {DashboardModel} from '../model'; + +describe('DashboardModel', function() { + + describe('when creating new dashboard model defaults only', function() { + var model; + + beforeEach(function() { + model = new DashboardModel({}, {}); + }); + + it('should have title', function() { + expect(model.title).to.be('No Title'); + }); + + it('should have meta', function() { + expect(model.meta.canSave).to.be(true); + expect(model.meta.canShare).to.be(true); + }); + + it('should have default properties', function() { + expect(model.rows.length).to.be(0); + }); + }); + + describe('when getting next panel id', function() { + var model; + + beforeEach(function() { + model = new DashboardModel({ + rows: [{ panels: [{ id: 5 }]}] + }); + }); + + it('should return max id + 1', function() { + expect(model.getNextPanelId()).to.be(6); + }); + }); + + describe('getSaveModelClone', function() { + it('should sort keys', () => { + var model = new DashboardModel({}); + var saveModel = model.getSaveModelClone(); + var keys = _.keys(saveModel); + + expect(keys[0]).to.be('addEmptyRow'); + expect(keys[1]).to.be('addPanel'); + }); + }); + + describe('row and panel manipulation', function() { + var dashboard; + + beforeEach(function() { + dashboard = new DashboardModel({}); + }); + + it('adding default should split span in half', function() { + dashboard.addEmptyRow(); + dashboard.rows[0].addPanel({span: 12}); + dashboard.rows[0].addPanel({span: 12}); + + expect(dashboard.rows[0].panels[0].span).to.be(6); + expect(dashboard.rows[0].panels[1].span).to.be(6); + }); + + it('duplicate panel should try to add it to same row', function() { + var panel = { span: 4, attr: '123', id: 10 }; + + dashboard.addEmptyRow(); + dashboard.rows[0].addPanel(panel); + dashboard.duplicatePanel(panel, dashboard.rows[0]); + + expect(dashboard.rows[0].panels[0].span).to.be(4); + expect(dashboard.rows[0].panels[1].span).to.be(4); + expect(dashboard.rows[0].panels[1].attr).to.be('123'); + expect(dashboard.rows[0].panels[1].id).to.be(11); + }); + + it('duplicate panel should remove repeat data', function() { + var panel = { span: 4, attr: '123', id: 10, repeat: 'asd', scopedVars: { test: 'asd' }}; + + dashboard.addEmptyRow(); + dashboard.rows[0].addPanel(panel); + dashboard.duplicatePanel(panel, dashboard.rows[0]); + + expect(dashboard.rows[0].panels[1].repeat).to.be(undefined); + expect(dashboard.rows[0].panels[1].scopedVars).to.be(undefined); + }); + + }); + + describe('when creating dashboard with old schema', function() { + var model; + var graph; + var singlestat; + var table; + + beforeEach(function() { + model = new DashboardModel({ + services: { filter: { time: { from: 'now-1d', to: 'now'}, list: [{}] }}, + pulldowns: [ + {type: 'filtering', enable: true}, + {type: 'annotations', enable: true, annotations: [{name: 'old'}]} + ], + rows: [ + { + panels: [ + { + type: 'graph', legend: true, aliasYAxis: { test: 2 }, + y_formats: ['kbyte', 'ms'], + grid: { + min: 1, + max: 10, + rightMin: 5, + rightMax: 15, + leftLogBase: 1, + rightLogBase: 2, + threshold1: 200, + threshold2: 400, + threshold1Color: 'yellow', + threshold2Color: 'red', + }, + leftYAxisLabel: 'left label', + targets: [{refId: 'A'}, {}], + }, + { + type: 'singlestat', legend: true, thresholds: '10,20,30', aliasYAxis: { test: 2 }, grid: { min: 1, max: 10 }, + targets: [{refId: 'A'}, {}], + }, + { + type: 'table', legend: true, styles: [{ thresholds: ["10", "20", "30"]}, { thresholds: ["100", "200", "300"]}], + targets: [{refId: 'A'}, {}], + } + ] + } + ] + }); + + graph = model.rows[0].panels[0]; + singlestat = model.rows[0].panels[1]; + table = model.rows[0].panels[2]; + }); + + it('should have title', function() { + expect(model.title).to.be('No Title'); + }); + + it('should have panel id', function() { + expect(graph.id).to.be(1); + }); + + it('should move time and filtering list', function() { + expect(model.time.from).to.be('now-1d'); + expect(model.templating.list[0].allFormat).to.be('glob'); + }); + + it('graphite panel should change name too graph', function() { + expect(graph.type).to.be('graph'); + }); + + it('single stat panel should have two thresholds', function() { + expect(singlestat.thresholds).to.be('20,30'); + }); + + it('queries without refId should get it', function() { + expect(graph.targets[1].refId).to.be('B'); + }); + + it('update legend setting', function() { + expect(graph.legend.show).to.be(true); + }); + + it('move aliasYAxis to series override', function() { + expect(graph.seriesOverrides[0].alias).to.be("test"); + expect(graph.seriesOverrides[0].yaxis).to.be(2); + }); + + it('should move pulldowns to new schema', function() { + expect(model.annotations.list[0].name).to.be('old'); + }); + + it('table panel should only have two thresholds values', function() { + expect(table.styles[0].thresholds[0]).to.be("20"); + expect(table.styles[0].thresholds[1]).to.be("30"); + expect(table.styles[1].thresholds[0]).to.be("200"); + expect(table.styles[1].thresholds[1]).to.be("300"); + }); + + it('graph grid to yaxes options', function() { + expect(graph.yaxes[0].min).to.be(1); + expect(graph.yaxes[0].max).to.be(10); + expect(graph.yaxes[0].format).to.be('kbyte'); + expect(graph.yaxes[0].label).to.be('left label'); + expect(graph.yaxes[0].logBase).to.be(1); + expect(graph.yaxes[1].min).to.be(5); + expect(graph.yaxes[1].max).to.be(15); + expect(graph.yaxes[1].format).to.be('ms'); + expect(graph.yaxes[1].logBase).to.be(2); + + expect(graph.grid.rightMax).to.be(undefined); + expect(graph.grid.rightLogBase).to.be(undefined); + expect(graph.y_formats).to.be(undefined); + }); + + it('dashboard schema version should be set to latest', function() { + expect(model.schemaVersion).to.be(14); + }); + + it('graph thresholds should be migrated', function() { + expect(graph.thresholds.length).to.be(2); + expect(graph.thresholds[0].op).to.be('gt'); + expect(graph.thresholds[0].value).to.be(200); + expect(graph.thresholds[0].fillColor).to.be('yellow'); + expect(graph.thresholds[1].value).to.be(400); + expect(graph.thresholds[1].fillColor).to.be('red'); + }); + }); + + describe('when creating dashboard model with missing list for annoations or templating', function() { + var model; + + beforeEach(function() { + model = new DashboardModel({ + annotations: { + enable: true, + }, + templating: { + enable: true + } + }); + }); + + it('should add empty list', function() { + expect(model.annotations.list.length).to.be(0); + expect(model.templating.list.length).to.be(0); + }); + }); + + describe('Given editable false dashboard', function() { + var model; + + beforeEach(function() { + model = new DashboardModel({editable: false}); + }); + + it('Should set meta canEdit and canSave to false', function() { + expect(model.meta.canSave).to.be(false); + expect(model.meta.canEdit).to.be(false); + }); + + it('getSaveModelClone should remove meta', function() { + var clone = model.getSaveModelClone(); + expect(clone.meta).to.be(undefined); + }); + }); + + describe('when loading dashboard with old influxdb query schema', function() { + var model; + var target; + + beforeEach(function() { + model = new DashboardModel({ + rows: [{ + panels: [{ + type: 'graph', + grid: {}, + yaxes: [{}, {}], + targets: [{ + "alias": "$tag_datacenter $tag_source $col", + "column": "value", + "measurement": "logins.count", + "fields": [ + { + "func": "mean", + "name": "value", + "mathExpr": "*2", + "asExpr": "value" + }, + { + "name": "one-minute", + "func": "mean", + "mathExpr": "*3", + "asExpr": "one-minute" + } + ], + "tags": [], + "fill": "previous", + "function": "mean", + "groupBy": [ + { + "interval": "auto", + "type": "time" + }, + { + "key": "source", + "type": "tag" + }, + { + "type": "tag", + "key": "datacenter" + } + ], + }] + }] + }] + }); + + target = model.rows[0].panels[0].targets[0]; + }); + + it('should update query schema', function() { + expect(target.fields).to.be(undefined); + expect(target.select.length).to.be(2); + expect(target.select[0].length).to.be(4); + expect(target.select[0][0].type).to.be('field'); + expect(target.select[0][1].type).to.be('mean'); + expect(target.select[0][2].type).to.be('math'); + expect(target.select[0][3].type).to.be('alias'); + }); + + }); + + describe('when creating dashboard model with missing list for annoations or templating', function() { + var model; + + beforeEach(function() { + model = new DashboardModel({ + annotations: { + enable: true, + }, + templating: { + enable: true + } + }); + }); + + it('should add empty list', function() { + expect(model.annotations.list.length).to.be(0); + expect(model.templating.list.length).to.be(0); + }); + }); + + describe('Formatting epoch timestamp when timezone is set as utc', function() { + var dashboard; + + beforeEach(function() { + dashboard = new DashboardModel({timezone: 'utc'}); + }); + + it('Should format timestamp with second resolution by default', function() { + expect(dashboard.formatDate(1234567890000)).to.be('2009-02-13 23:31:30'); + }); + + it('Should format timestamp with second resolution even if second format is passed as parameter', function() { + expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss')).to.be('2009-02-13 23:31:30'); + }); + + it('Should format timestamp with millisecond resolution if format is passed as parameter', function() { + expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss.SSS')).to.be('2009-02-13 23:31:30.007'); + }); + }); + +}); diff --git a/public/app/features/dashboard/specs/dashboard_srv_specs.ts b/public/app/features/dashboard/specs/dashboard_srv_specs.ts index 974b8600c0c..666a3bc0bf2 100644 --- a/public/app/features/dashboard/specs/dashboard_srv_specs.ts +++ b/public/app/features/dashboard/specs/dashboard_srv_specs.ts @@ -9,370 +9,4 @@ describe('dashboardSrv', function() { _dashboardSrv = new DashboardSrv({}, {}, {}); }); - describe('when creating new dashboard with defaults only', function() { - var model; - - beforeEach(function() { - model = _dashboardSrv.create({}, {}); - }); - - it('should have title', function() { - expect(model.title).to.be('No Title'); - }); - - it('should have meta', function() { - expect(model.meta.canSave).to.be(true); - expect(model.meta.canShare).to.be(true); - }); - - it('should have default properties', function() { - expect(model.rows.length).to.be(0); - }); - }); - - describe('when getting next panel id', function() { - var model; - - beforeEach(function() { - model = _dashboardSrv.create({ - rows: [{ panels: [{ id: 5 }]}] - }); - }); - - it('should return max id + 1', function() { - expect(model.getNextPanelId()).to.be(6); - }); - }); - - describe('row and panel manipulation', function() { - var dashboard; - - beforeEach(function() { - dashboard = _dashboardSrv.create({}); - }); - - it('adding default should split span in half', function() { - dashboard.addEmptyRow(); - dashboard.rows[0].addPanel({span: 12}); - dashboard.rows[0].addPanel({span: 12}); - - expect(dashboard.rows[0].panels[0].span).to.be(6); - expect(dashboard.rows[0].panels[1].span).to.be(6); - }); - - it('duplicate panel should try to add it to same row', function() { - var panel = { span: 4, attr: '123', id: 10 }; - - dashboard.addEmptyRow(); - dashboard.rows[0].addPanel(panel); - dashboard.duplicatePanel(panel, dashboard.rows[0]); - - expect(dashboard.rows[0].panels[0].span).to.be(4); - expect(dashboard.rows[0].panels[1].span).to.be(4); - expect(dashboard.rows[0].panels[1].attr).to.be('123'); - expect(dashboard.rows[0].panels[1].id).to.be(11); - }); - - it('duplicate panel should remove repeat data', function() { - var panel = { span: 4, attr: '123', id: 10, repeat: 'asd', scopedVars: { test: 'asd' }}; - - dashboard.addEmptyRow(); - dashboard.rows[0].addPanel(panel); - dashboard.duplicatePanel(panel, dashboard.rows[0]); - - expect(dashboard.rows[0].panels[1].repeat).to.be(undefined); - expect(dashboard.rows[0].panels[1].scopedVars).to.be(undefined); - }); - - }); - - describe('when creating dashboard with editable false', function() { - var model; - - beforeEach(function() { - model = _dashboardSrv.create({ - editable: false - }); - }); - - it('should set editable false', function() { - expect(model.editable).to.be(false); - }); - - }); - - describe('when creating dashboard with old schema', function() { - var model; - var graph; - var singlestat; - var table; - - beforeEach(function() { - model = _dashboardSrv.create({ - services: { filter: { time: { from: 'now-1d', to: 'now'}, list: [{}] }}, - pulldowns: [ - {type: 'filtering', enable: true}, - {type: 'annotations', enable: true, annotations: [{name: 'old'}]} - ], - rows: [ - { - panels: [ - { - type: 'graph', legend: true, aliasYAxis: { test: 2 }, - y_formats: ['kbyte', 'ms'], - grid: { - min: 1, - max: 10, - rightMin: 5, - rightMax: 15, - leftLogBase: 1, - rightLogBase: 2, - threshold1: 200, - threshold2: 400, - threshold1Color: 'yellow', - threshold2Color: 'red', - }, - leftYAxisLabel: 'left label', - targets: [{refId: 'A'}, {}], - }, - { - type: 'singlestat', legend: true, thresholds: '10,20,30', aliasYAxis: { test: 2 }, grid: { min: 1, max: 10 }, - targets: [{refId: 'A'}, {}], - }, - { - type: 'table', legend: true, styles: [{ thresholds: ["10", "20", "30"]}, { thresholds: ["100", "200", "300"]}], - targets: [{refId: 'A'}, {}], - } - ] - } - ] - }); - - graph = model.rows[0].panels[0]; - singlestat = model.rows[0].panels[1]; - table = model.rows[0].panels[2]; - }); - - it('should have title', function() { - expect(model.title).to.be('No Title'); - }); - - it('should have panel id', function() { - expect(graph.id).to.be(1); - }); - - it('should move time and filtering list', function() { - expect(model.time.from).to.be('now-1d'); - expect(model.templating.list[0].allFormat).to.be('glob'); - }); - - it('graphite panel should change name too graph', function() { - expect(graph.type).to.be('graph'); - }); - - it('single stat panel should have two thresholds', function() { - expect(singlestat.thresholds).to.be('20,30'); - }); - - it('queries without refId should get it', function() { - expect(graph.targets[1].refId).to.be('B'); - }); - - it('update legend setting', function() { - expect(graph.legend.show).to.be(true); - }); - - it('move aliasYAxis to series override', function() { - expect(graph.seriesOverrides[0].alias).to.be("test"); - expect(graph.seriesOverrides[0].yaxis).to.be(2); - }); - - it('should move pulldowns to new schema', function() { - expect(model.annotations.list[0].name).to.be('old'); - }); - - it('table panel should only have two thresholds values', function() { - expect(table.styles[0].thresholds[0]).to.be("20"); - expect(table.styles[0].thresholds[1]).to.be("30"); - expect(table.styles[1].thresholds[0]).to.be("200"); - expect(table.styles[1].thresholds[1]).to.be("300"); - }); - - it('graph grid to yaxes options', function() { - expect(graph.yaxes[0].min).to.be(1); - expect(graph.yaxes[0].max).to.be(10); - expect(graph.yaxes[0].format).to.be('kbyte'); - expect(graph.yaxes[0].label).to.be('left label'); - expect(graph.yaxes[0].logBase).to.be(1); - expect(graph.yaxes[1].min).to.be(5); - expect(graph.yaxes[1].max).to.be(15); - expect(graph.yaxes[1].format).to.be('ms'); - expect(graph.yaxes[1].logBase).to.be(2); - - expect(graph.grid.rightMax).to.be(undefined); - expect(graph.grid.rightLogBase).to.be(undefined); - expect(graph.y_formats).to.be(undefined); - }); - - it('dashboard schema version should be set to latest', function() { - expect(model.schemaVersion).to.be(14); - }); - - it('graph thresholds should be migrated', function() { - expect(graph.thresholds.length).to.be(2); - expect(graph.thresholds[0].op).to.be('gt'); - expect(graph.thresholds[0].value).to.be(200); - expect(graph.thresholds[0].fillColor).to.be('yellow'); - expect(graph.thresholds[1].value).to.be(400); - expect(graph.thresholds[1].fillColor).to.be('red'); - }); - }); - - describe('when creating dashboard model with missing list for annoations or templating', function() { - var model; - - beforeEach(function() { - model = _dashboardSrv.create({ - annotations: { - enable: true, - }, - templating: { - enable: true - } - }); - }); - - it('should add empty list', function() { - expect(model.annotations.list.length).to.be(0); - expect(model.templating.list.length).to.be(0); - }); - }); - - describe('Given editable false dashboard', function() { - var model; - - beforeEach(function() { - model = _dashboardSrv.create({ - editable: false, - }); - }); - - it('Should set meta canEdit and canSave to false', function() { - expect(model.meta.canSave).to.be(false); - expect(model.meta.canEdit).to.be(false); - }); - - it('getSaveModelClone should remove meta', function() { - var clone = model.getSaveModelClone(); - expect(clone.meta).to.be(undefined); - }); - }); - - describe('when loading dashboard with old influxdb query schema', function() { - var model; - var target; - - beforeEach(function() { - model = _dashboardSrv.create({ - rows: [{ - panels: [{ - type: 'graph', - grid: {}, - yaxes: [{}, {}], - targets: [{ - "alias": "$tag_datacenter $tag_source $col", - "column": "value", - "measurement": "logins.count", - "fields": [ - { - "func": "mean", - "name": "value", - "mathExpr": "*2", - "asExpr": "value" - }, - { - "name": "one-minute", - "func": "mean", - "mathExpr": "*3", - "asExpr": "one-minute" - } - ], - "tags": [], - "fill": "previous", - "function": "mean", - "groupBy": [ - { - "interval": "auto", - "type": "time" - }, - { - "key": "source", - "type": "tag" - }, - { - "type": "tag", - "key": "datacenter" - } - ], - }] - }] - }] - }); - - target = model.rows[0].panels[0].targets[0]; - }); - - it('should update query schema', function() { - expect(target.fields).to.be(undefined); - expect(target.select.length).to.be(2); - expect(target.select[0].length).to.be(4); - expect(target.select[0][0].type).to.be('field'); - expect(target.select[0][1].type).to.be('mean'); - expect(target.select[0][2].type).to.be('math'); - expect(target.select[0][3].type).to.be('alias'); - }); - - }); - - describe('when creating dashboard model with missing list for annoations or templating', function() { - var model; - - beforeEach(function() { - model = _dashboardSrv.create({ - annotations: { - enable: true, - }, - templating: { - enable: true - } - }); - }); - - it('should add empty list', function() { - expect(model.annotations.list.length).to.be(0); - expect(model.templating.list.length).to.be(0); - }); - }); - - describe('Formatting epoch timestamp when timezone is set as utc', function() { - var dashboard; - - beforeEach(function() { - dashboard = _dashboardSrv.create({ - timezone: 'utc', - }); - }); - - it('Should format timestamp with second resolution by default', function() { - expect(dashboard.formatDate(1234567890000)).to.be('2009-02-13 23:31:30'); - }); - - it('Should format timestamp with second resolution even if second format is passed as parameter', function() { - expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss')).to.be('2009-02-13 23:31:30'); - }); - - it('Should format timestamp with millisecond resolution if format is passed as parameter', function() { - expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss.SSS')).to.be('2009-02-13 23:31:30.007'); - }); - }); }); diff --git a/public/app/features/dashboard/specs/time_srv_specs.ts b/public/app/features/dashboard/specs/time_srv_specs.ts new file mode 100644 index 00000000000..3b008738430 --- /dev/null +++ b/public/app/features/dashboard/specs/time_srv_specs.ts @@ -0,0 +1,110 @@ +import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; + +import helpers from 'test/specs/helpers'; +import _ from 'lodash'; +import TimeSrv from '../time_srv'; +import moment from 'moment'; + +describe('timeSrv', function() { + var ctx = new helpers.ServiceTestContext(); + var _dashboard: any = { + time: {from: 'now-6h', to: 'now'}, + }; + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.services')); + beforeEach(ctx.createService('timeSrv')); + + beforeEach(function() { + ctx.service.init(_dashboard); + }); + + describe('timeRange', function() { + it('should return unparsed when parse is false', function() { + ctx.service.setTime({from: 'now', to: 'now-1h' }); + var time = ctx.service.timeRange(); + expect(time.raw.from).to.be('now'); + expect(time.raw.to).to.be('now-1h'); + }); + + it('should return parsed when parse is true', function() { + ctx.service.setTime({from: 'now', to: 'now-1h' }); + var time = ctx.service.timeRange(); + expect(moment.isMoment(time.from)).to.be(true); + expect(moment.isMoment(time.to)).to.be(true); + }); + }); + + describe('init time from url', function() { + it('should handle relative times', function() { + ctx.$location.search({from: 'now-2d', to: 'now'}); + ctx.service.init(_dashboard); + var time = ctx.service.timeRange(); + expect(time.raw.from).to.be('now-2d'); + expect(time.raw.to).to.be('now'); + }); + + it('should handle formated dates', function() { + ctx.$location.search({from: '20140410T052010', to: '20140520T031022'}); + ctx.service.init(_dashboard); + var time = ctx.service.timeRange(true); + expect(time.from.valueOf()).to.equal(new Date("2014-04-10T05:20:10Z").getTime()); + expect(time.to.valueOf()).to.equal(new Date("2014-05-20T03:10:22Z").getTime()); + }); + + it('should handle formated dates without time', function() { + ctx.$location.search({from: '20140410', to: '20140520'}); + ctx.service.init(_dashboard); + var time = ctx.service.timeRange(true); + expect(time.from.valueOf()).to.equal(new Date("2014-04-10T00:00:00Z").getTime()); + expect(time.to.valueOf()).to.equal(new Date("2014-05-20T00:00:00Z").getTime()); + }); + + it('should handle epochs', function() { + ctx.$location.search({from: '1410337646373', to: '1410337665699'}); + ctx.service.init(_dashboard); + var time = ctx.service.timeRange(true); + expect(time.from.valueOf()).to.equal(1410337646373); + expect(time.to.valueOf()).to.equal(1410337665699); + }); + + it('should handle bad dates', function() { + ctx.$location.search({from: '20151126T00010%3C%2Fp%3E%3Cspan%20class', to: 'now'}); + _dashboard.time.from = 'now-6h'; + ctx.service.init(_dashboard); + expect(ctx.service.time.from).to.equal('now-6h'); + expect(ctx.service.time.to).to.equal('now'); + }); + }); + + describe('setTime', function() { + it('should return disable refresh if refresh is disabled for any range', function() { + _dashboard.refresh = false; + + ctx.service.setTime({from: '2011-01-01', to: '2015-01-01' }); + expect(_dashboard.refresh).to.be(false); + }); + + it('should restore refresh for absolute time range', function() { + _dashboard.refresh = '30s'; + + ctx.service.setTime({from: '2011-01-01', to: '2015-01-01' }); + expect(_dashboard.refresh).to.be('30s'); + }); + + it('should restore refresh after relative time range is set', function() { + _dashboard.refresh = '10s'; + ctx.service.setTime({from: moment([2011,1,1]), to: moment([2015,1,1])}); + expect(_dashboard.refresh).to.be(false); + ctx.service.setTime({from: '2011-01-01', to: 'now' }); + expect(_dashboard.refresh).to.be('10s'); + }); + + it('should keep refresh after relative time range is changed and now delay exists', function() { + _dashboard.refresh = '10s'; + ctx.service.setTime({from: 'now-1h', to: 'now-10s' }); + expect(_dashboard.refresh).to.be('10s'); + }); + }); + +}); diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/submenu/submenu.html index 4b8cd3e1e2b..3e09fe4425e 100644 --- a/public/app/features/dashboard/submenu/submenu.html +++ b/public/app/features/dashboard/submenu/submenu.html @@ -5,7 +5,7 @@ - +
diff --git a/public/app/features/dashboard/submenu/submenu.ts b/public/app/features/dashboard/submenu/submenu.ts index 2ecb298236c..a925ba97f0d 100644 --- a/public/app/features/dashboard/submenu/submenu.ts +++ b/public/app/features/dashboard/submenu/submenu.ts @@ -21,10 +21,6 @@ export class SubmenuCtrl { this.$rootScope.$broadcast('refresh'); } - getValuesForTag(variable, tagKey) { - return this.variableSrv.getValuesForTag(variable, tagKey); - } - variableUpdated(variable) { this.variableSrv.variableUpdated(variable).then(() => { this.$rootScope.$emit('template-variable-value-updated'); diff --git a/public/app/features/dashboard/timeSrv.js b/public/app/features/dashboard/timeSrv.js deleted file mode 100644 index f83c2bf0517..00000000000 --- a/public/app/features/dashboard/timeSrv.js +++ /dev/null @@ -1,168 +0,0 @@ -define([ - 'angular', - 'lodash', - 'moment', - 'app/core/config', - 'app/core/utils/kbn', - 'app/core/utils/datemath' -], function (angular, _, moment, config, kbn, dateMath) { - 'use strict'; - - var module = angular.module('grafana.services'); - - module.service('timeSrv', function($rootScope, $timeout, $routeParams, timer) { - var self = this; - - // default time - this.time = {from: '6h', to: 'now'}; - - $rootScope.$on('zoom-out', function(e, factor) { self.zoomOut(factor); }); - - this.init = function(dashboard) { - timer.cancel_all(); - - this.dashboard = dashboard; - this.time = dashboard.time; - this.refresh = dashboard.refresh; - - this._initTimeFromUrl(); - this._parseTime(); - - if(this.refresh) { - this.setAutoRefresh(this.refresh); - } - }; - - this._parseTime = function() { - // when absolute time is saved in json it is turned to a string - if (_.isString(this.time.from) && this.time.from.indexOf('Z') >= 0) { - this.time.from = moment(this.time.from).utc(); - } - if (_.isString(this.time.to) && this.time.to.indexOf('Z') >= 0) { - this.time.to = moment(this.time.to).utc(); - } - }; - - this._parseUrlParam = function(value) { - if (value.indexOf('now') !== -1) { - return value; - } - if (value.length === 8) { - return moment.utc(value, 'YYYYMMDD'); - } - if (value.length === 15) { - return moment.utc(value, 'YYYYMMDDTHHmmss'); - } - - if (!isNaN(value)) { - var epoch = parseInt(value); - return moment.utc(epoch); - } - - return null; - }; - - this._initTimeFromUrl = function() { - if ($routeParams.from) { - this.time.from = this._parseUrlParam($routeParams.from) || this.time.from; - } - if ($routeParams.to) { - this.time.to = this._parseUrlParam($routeParams.to) || this.time.to; - } - if ($routeParams.refresh) { - this.refresh = $routeParams.refresh || this.refresh; - } - }; - - this.setAutoRefresh = function (interval) { - this.dashboard.refresh = interval; - if (interval) { - var interval_ms = kbn.interval_to_ms(interval); - $timeout(function () { - self.start_scheduled_refresh(interval_ms); - self.refreshDashboard(); - }, interval_ms); - } else { - this.cancel_scheduled_refresh(); - } - }; - - this.refreshDashboard = function() { - $rootScope.$broadcast('refresh'); - }; - - this.start_scheduled_refresh = function (after_ms) { - self.cancel_scheduled_refresh(); - self.refresh_timer = timer.register($timeout(function () { - self.start_scheduled_refresh(after_ms); - self.refreshDashboard(); - }, after_ms)); - }; - - this.cancel_scheduled_refresh = function () { - timer.cancel(this.refresh_timer); - }; - - this.setTime = function(time, enableRefresh) { - _.extend(this.time, time); - - // disable refresh if zoom in or zoom out - if (!enableRefresh && moment.isMoment(time.to)) { - this.old_refresh = this.dashboard.refresh || this.old_refresh; - this.setAutoRefresh(false); - } - else if (this.old_refresh && this.old_refresh !== this.dashboard.refresh) { - this.setAutoRefresh(this.old_refresh); - this.old_refresh = null; - } - - $rootScope.appEvent('time-range-changed', this.time); - $timeout(this.refreshDashboard, 0); - }; - - this.timeRangeForUrl = function() { - var range = this.timeRange().raw; - - if (moment.isMoment(range.from)) { range.from = range.from.valueOf(); } - if (moment.isMoment(range.to)) { range.to = range.to.valueOf(); } - - return range; - }; - - this.timeRange = function() { - // make copies if they are moment (do not want to return out internal moment, because they are mutable!) - var range = { - from: moment.isMoment(this.time.from) ? moment(this.time.from) : this.time.from, - to: moment.isMoment(this.time.to) ? moment(this.time.to) : this.time.to, - }; - - range = { - from: dateMath.parse(range.from, false), - to: dateMath.parse(range.to, true), - raw: range - }; - - return range; - }; - - this.zoomOut = function(factor) { - var range = this.timeRange(); - - var timespan = (range.to.valueOf() - range.from.valueOf()); - var center = range.to.valueOf() - timespan/2; - - var to = (center + (timespan*factor)/2); - var from = (center - (timespan*factor)/2); - - if (to > Date.now() && range.to <= Date.now()) { - var offset = to - Date.now(); - from = from - offset; - to = Date.now(); - } - - this.setTime({from: moment.utc(from), to: moment.utc(to) }); - }; - - }); - -}); diff --git a/public/app/features/dashboard/time_srv.ts b/public/app/features/dashboard/time_srv.ts new file mode 100644 index 00000000000..c7a0804bed0 --- /dev/null +++ b/public/app/features/dashboard/time_srv.ts @@ -0,0 +1,209 @@ +/// + +import config from 'app/core/config'; +import angular from 'angular'; +import moment from 'moment'; +import _ from 'lodash'; +import coreModule from 'app/core/core_module'; +import kbn from 'app/core/utils/kbn'; +import * as dateMath from 'app/core/utils/datemath'; + +class TimeSrv { + time: any; + refreshTimer: any; + refresh: boolean; + oldRefresh: boolean; + dashboard: any; + timeAtLoad: any; + + /** @ngInject **/ + constructor(private $rootScope, private $timeout, private $location, private timer, private contextSrv) { + // default time + this.time = {from: '6h', to: 'now'}; + + $rootScope.$on('zoom-out', this.zoomOut.bind(this)); + $rootScope.$on('$routeUpdate', this.routeUpdated.bind(this)); + } + + init(dashboard) { + this.timer.cancelAll(); + + this.dashboard = dashboard; + this.time = dashboard.time; + this.refresh = dashboard.refresh; + + this.initTimeFromUrl(); + this.parseTime(); + + // remember time at load so we can go back to it + this.timeAtLoad = _.cloneDeep(this.time); + + if (this.refresh) { + this.setAutoRefresh(this.refresh); + } + } + + private parseTime() { + // when absolute time is saved in json it is turned to a string + if (_.isString(this.time.from) && this.time.from.indexOf('Z') >= 0) { + this.time.from = moment(this.time.from).utc(); + } + if (_.isString(this.time.to) && this.time.to.indexOf('Z') >= 0) { + this.time.to = moment(this.time.to).utc(); + } + }; + + private parseUrlParam(value) { + if (value.indexOf('now') !== -1) { + return value; + } + if (value.length === 8) { + return moment.utc(value, 'YYYYMMDD'); + } + if (value.length === 15) { + return moment.utc(value, 'YYYYMMDDTHHmmss'); + } + + if (!isNaN(value)) { + var epoch = parseInt(value); + return moment.utc(epoch); + } + + return null; + } + + private initTimeFromUrl() { + var params = this.$location.search(); + if (params.from) { + this.time.from = this.parseUrlParam(params.from) || this.time.from; + } + if (params.to) { + this.time.to = this.parseUrlParam(params.to) || this.time.to; + } + if (params.refresh) { + this.refresh = params.refresh || this.refresh; + } + }; + + private routeUpdated() { + var params = this.$location.search(); + var urlRange = this.timeRangeForUrl(); + // check if url has time range + if (params.from && params.to) { + // is it different from what our current time range? + if (params.from !== urlRange.from || params.to !== urlRange.to) { + // issue update + this.initTimeFromUrl(); + this.setTime(this.time, true); + } + } else { + this.setTime(this.timeAtLoad, true); + } + } + + setAutoRefresh(interval) { + this.dashboard.refresh = interval; + if (interval) { + var intervalMs = kbn.interval_to_ms(interval); + + this.$timeout(() => { + this.startNextRefreshTimer(intervalMs); + this.refreshDashboard(); + }, intervalMs); + + } else { + this.cancelNextRefresh(); + } + + // update url + var params = this.$location.search(); + params.refresh = interval; + this.$location.search(params); + } + + refreshDashboard() { + this.$rootScope.$broadcast('refresh'); + } + + private startNextRefreshTimer(afterMs) { + this.cancelNextRefresh(); + this.refreshTimer = this.timer.register(this.$timeout(() => { + this.startNextRefreshTimer(afterMs); + if (this.contextSrv.isGrafanaVisible()) { + this.refreshDashboard(); + } + }, afterMs)); + } + + private cancelNextRefresh() { + this.timer.cancel(this.refreshTimer); + }; + + setTime(time, fromRouteUpdate?) { + _.extend(this.time, time); + + // disable refresh if zoom in or zoom out + if (moment.isMoment(time.to)) { + this.oldRefresh = this.dashboard.refresh || this.oldRefresh; + this.setAutoRefresh(false); + } else if (this.oldRefresh && this.oldRefresh !== this.dashboard.refresh) { + this.setAutoRefresh(this.oldRefresh); + this.oldRefresh = null; + } + + // update url + if (fromRouteUpdate !== true) { + var urlRange = this.timeRangeForUrl(); + var urlParams = this.$location.search(); + urlParams.from = urlRange.from; + urlParams.to = urlRange.to; + this.$location.search(urlParams); + } + + this.$rootScope.appEvent('time-range-changed', this.time); + this.$timeout(this.refreshDashboard.bind(this), 0); + } + + timeRangeForUrl() { + var range = this.timeRange().raw; + + if (moment.isMoment(range.from)) { range.from = range.from.valueOf(); } + if (moment.isMoment(range.to)) { range.to = range.to.valueOf(); } + + return range; + } + + timeRange() { + // make copies if they are moment (do not want to return out internal moment, because they are mutable!) + var raw = { + from: moment.isMoment(this.time.from) ? moment(this.time.from) : this.time.from, + to: moment.isMoment(this.time.to) ? moment(this.time.to) : this.time.to, + }; + + return { + from: dateMath.parse(raw.from, false), + to: dateMath.parse(raw.to, true), + raw: raw + }; + } + + zoomOut(e, factor) { + var range = this.timeRange(); + + var timespan = (range.to.valueOf() - range.from.valueOf()); + var center = range.to.valueOf() - timespan/2; + + var to = (center + (timespan*factor)/2); + var from = (center - (timespan*factor)/2); + + if (to > Date.now() && range.to <= Date.now()) { + var offset = to - Date.now(); + from = from - offset; + to = Date.now(); + } + + this.setTime({from: moment.utc(from), to: moment.utc(to)}); + } +} + +coreModule.service('timeSrv', TimeSrv); diff --git a/public/app/features/dashboard/timepicker/timepicker.ts b/public/app/features/dashboard/timepicker/timepicker.ts index 49daad3df28..21404108678 100644 --- a/public/app/features/dashboard/timepicker/timepicker.ts +++ b/public/app/features/dashboard/timepicker/timepicker.ts @@ -97,8 +97,7 @@ export class TimePickerCtrl { from = range.from.valueOf(); } - this.timeSrv.setTime({from: moment.utc(from), to: moment.utc(to) }); - + this.timeSrv.setTime({from: moment.utc(from), to: moment.utc(to)}); } openDropdown() { @@ -126,7 +125,7 @@ export class TimePickerCtrl { this.timeSrv.setAutoRefresh(this.refresh.value); } - this.timeSrv.setTime(this.timeRaw, true); + this.timeSrv.setTime(this.timeRaw); this.$rootScope.appEvent('hide-dash-editor'); } diff --git a/public/app/features/dashboard/viewStateSrv.js b/public/app/features/dashboard/viewStateSrv.js index 993e8cfd09f..681e1377bad 100644 --- a/public/app/features/dashboard/viewStateSrv.js +++ b/public/app/features/dashboard/viewStateSrv.js @@ -8,7 +8,7 @@ function (angular, _, $) { var module = angular.module('grafana.services'); - module.factory('dashboardViewStateSrv', function($location, $timeout, templateSrv, contextSrv, timeSrv) { + module.factory('dashboardViewStateSrv', function($location, $timeout) { // represents the transient view state // like fullscreen panel & edit @@ -25,15 +25,6 @@ function (angular, _, $) { } }; - // update url on time range change - $scope.onAppEvent('time-range-changed', function() { - var urlParams = $location.search(); - var urlRange = timeSrv.timeRangeForUrl(); - urlParams.from = urlRange.from; - urlParams.to = urlRange.to; - $location.search(urlParams); - }); - $scope.onAppEvent('$routeUpdate', function() { var urlState = self.getQueryStringState(); if (self.needsSync(urlState)) { @@ -82,7 +73,7 @@ function (angular, _, $) { return urlState; }; - DashboardViewState.prototype.update = function(state) { + DashboardViewState.prototype.update = function(state, fromRouteUpdated) { // implement toggle logic if (state.toggle) { delete state.toggle; @@ -113,7 +104,12 @@ function (angular, _, $) { delete this.state.tab; } - $location.search(this.serializeToUrl()); + // do not update url params if we are here + // from routeUpdated event + if (fromRouteUpdated !== true) { + $location.search(this.serializeToUrl()); + } + this.syncState(); }; diff --git a/public/app/features/org/orgApiKeysCtrl.js b/public/app/features/org/orgApiKeysCtrl.js index 7d2bc4be1c6..d0b08b57d32 100644 --- a/public/app/features/org/orgApiKeysCtrl.js +++ b/public/app/features/org/orgApiKeysCtrl.js @@ -30,6 +30,7 @@ function (angular) { var modalScope = $scope.$new(true); modalScope.key = result.key; + modalScope.rootPath = window.location.origin + $scope.$root.appSubUrl; $scope.appEvent('show-modal', { src: 'public/app/features/org/partials/apikeyModal.html', diff --git a/public/app/features/org/partials/apikeyModal.html b/public/app/features/org/partials/apikeyModal.html index a4f59e1b86e..eeefcafc634 100644 --- a/public/app/features/org/partials/apikeyModal.html +++ b/public/app/features/org/partials/apikeyModal.html @@ -27,7 +27,7 @@

-curl -H "Authorization: Bearer your_key_above" http://your.grafana.com/api/dashboards/db/mydash
+curl -H "Authorization: Bearer {{key}}" {{rootPath}}/api/dashboards/home
 			
diff --git a/public/app/features/org/profile_ctrl.ts b/public/app/features/org/profile_ctrl.ts index a97799f1e85..c0e8b5ae246 100644 --- a/public/app/features/org/profile_ctrl.ts +++ b/public/app/features/org/profile_ctrl.ts @@ -9,7 +9,7 @@ export class ProfileCtrl { old_theme: any; orgs: any = []; userForm: any; - showOrgsList: boolean = false; + showOrgsList = false; /** @ngInject **/ constructor(private backendSrv, private contextSrv, private $location) { diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index 5dbd4d4a4e4..d37a3f5db41 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -199,6 +199,13 @@ class MetricsPanelCtrl extends PanelCtrl { return this.$q.when([]); } + // make shallow copy of scoped vars, + // and add built in variables interval and interval_ms + var scopedVars = Object.assign({}, this.panel.scopedVars, { + "__interval": {text: this.interval, value: this.interval}, + "__interval_ms": {text: this.intervalMs, value: this.intervalMs}, + }); + var metricsQuery = { panelId: this.panel.id, range: this.range, @@ -208,7 +215,7 @@ class MetricsPanelCtrl extends PanelCtrl { targets: this.panel.targets, format: this.panel.renderer === 'png' ? 'png' : 'json', maxDataPoints: this.resolution, - scopedVars: this.panel.scopedVars, + scopedVars: scopedVars, cacheTimeout: this.panel.cacheTimeout }; diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index 7be578c7fde..83c79f4123b 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -252,7 +252,7 @@ export class PanelCtrl { if (!!this.panel.description) { return 'info'; } - if (this.panel.links.length > 0) { + if (this.panel.links && this.panel.links.length) { return 'links'; } return ''; diff --git a/public/app/features/panel/panel_directive.ts b/public/app/features/panel/panel_directive.ts index 4876fea2ac7..24977bd386c 100644 --- a/public/app/features/panel/panel_directive.ts +++ b/public/app/features/panel/panel_directive.ts @@ -156,7 +156,7 @@ module.directive('grafanaPanel', function($rootScope) { content: function() { return ctrl.getInfoContent({mode: 'tooltip'}); }, - position: 'right middle', + position: 'top center', classes: ctrl.error ? 'drop-error' : 'drop-help', openOn: 'hover', hoverOpenDelay: 100, @@ -190,7 +190,7 @@ module.directive('grafanaPanel', function($rootScope) { module.directive('panelResizer', function($rootScope) { return { restrict: 'E', - template: '', + template: '', link: function(scope, elem) { var resizing = false; var lastPanel; diff --git a/public/app/features/playlist/playlist_edit_ctrl.ts b/public/app/features/playlist/playlist_edit_ctrl.ts index 702d7c8fa84..d291b7c97d5 100644 --- a/public/app/features/playlist/playlist_edit_ctrl.ts +++ b/public/app/features/playlist/playlist_edit_ctrl.ts @@ -8,8 +8,8 @@ import config from 'app/core/config'; export class PlaylistEditCtrl { filteredDashboards: any = []; filteredTags: any = []; - searchQuery: string = ''; - loading: boolean = false; + searchQuery = ''; + loading = false; playlist: any = { interval: '5m', }; diff --git a/public/app/features/templating/partials/editor.html b/public/app/features/templating/partials/editor.html index 8006e356d85..4fc2553244c 100644 --- a/public/app/features/templating/partials/editor.html +++ b/public/app/features/templating/partials/editor.html @@ -111,14 +111,14 @@ Values
-
- Auto option - -
+
+ + +
- Auto steps How many times should the current time range be divided to calculate the value + Step count How many times should the current time range be divided to calculate the value
@@ -257,27 +257,26 @@
Value groups/tags (Experimental feature)
-
- -
-
- Tags query - -
-
-
  • Tag values query
  • - -
    -
    + + +
    + Tags query + +
    +
    +
  • Tag values query
  • + +
    +
    -
    -
    Preview of values (shows max 20)
    -
    -
    - {{option.text}} -
    -
    -
    +
    +
    Preview of values (shows max 20)
    +
    +
    + {{option.text}} +
    +
    +
    {{infoText}} diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts index e083aa2aab5..92b150f2f15 100644 --- a/public/app/features/templating/query_variable.ts +++ b/public/app/features/templating/query_variable.ts @@ -21,6 +21,10 @@ export class QueryVariable implements Variable { name: string; multi: boolean; includeAll: boolean; + useTags: boolean; + tagsQuery: string; + tagValuesQuery: string; + tags: any[]; defaults = { type: 'query', @@ -37,8 +41,10 @@ export class QueryVariable implements Variable { allValue: null, options: [], current: {}, - tagsQuery: null, - tagValuesQuery: null, + tags: [], + useTags: false, + tagsQuery: "", + tagValuesQuery: "", }; /** @ngInject **/ @@ -77,9 +83,37 @@ export class QueryVariable implements Variable { updateOptions() { return this.datasourceSrv.get(this.datasource) .then(this.updateOptionsFromMetricFindQuery.bind(this)) + .then(this.updateTags.bind(this)) .then(this.variableSrv.validateVariableSelectionState.bind(this.variableSrv, this)); } + updateTags(datasource) { + if (this.useTags) { + return datasource.metricFindQuery(this.tagsQuery).then(results => { + this.tags = []; + for (var i = 0; i < results.length; i++) { + this.tags.push(results[i].text); + } + return datasource; + }); + } else { + delete this.tags; + } + + return datasource; + } + + getValuesForTag(tagKey) { + return this.datasourceSrv.get(this.datasource).then(datasource => { + var query = this.tagValuesQuery.replace('$tag', tagKey); + return datasource.metricFindQuery(query).then(function (results) { + return _.map(results, function(value) { + return value.text; + }); + }); + }); + } + updateOptionsFromMetricFindQuery(datasource) { return datasource.metricFindQuery(this.query).then(results => { this.options = this.metricNamesToVariableValues(results); @@ -147,11 +181,11 @@ export class QueryVariable implements Variable { } else if (sortType === 2) { options = _.sortBy(options, function(opt) { var matches = opt.text.match(/.*?(\d+).*/); - if (!matches) { - return 0; - } else { - return parseInt(matches[1], 10); - } + if (!matches) { + return 0; + } else { + return parseInt(matches[1], 10); + } }); } diff --git a/public/app/features/templating/specs/template_srv_specs.ts b/public/app/features/templating/specs/template_srv_specs.ts index ca336e97ba3..673273f240a 100644 --- a/public/app/features/templating/specs/template_srv_specs.ts +++ b/public/app/features/templating/specs/template_srv_specs.ts @@ -8,6 +8,9 @@ describe('templateSrv', function() { beforeEach(angularMocks.module('grafana.core')); beforeEach(angularMocks.module('grafana.services')); + beforeEach(angularMocks.module($provide => { + $provide.value('timeSrv', {}); + })); beforeEach(angularMocks.inject(function(variableSrv, templateSrv) { _templateSrv = templateSrv; @@ -150,6 +153,11 @@ describe('templateSrv', function() { expect(result).to.be('test,build=test2'); }); + it('multi value and distributed should render when not string', function() { + var result = _templateSrv.formatValue(['test'], 'distributed', { name: 'build' }); + expect(result).to.be('test'); + }); + it('slash should be properly escaped in regex format', function() { var result = _templateSrv.formatValue('Gi3/14', 'regex'); expect(result).to.be('Gi3\\/14'); @@ -239,4 +247,16 @@ describe('templateSrv', function() { expect(target).to.be('Server: All, period: 13m'); }); }); + + describe('built in interval variables', function() { + beforeEach(function() { + initTemplateSrv([]); + }); + + it('should replace $__interval_ms with interval milliseconds', function() { + var target = _templateSrv.replace('10 * $__interval_ms', {"__interval_ms": {text: "100", value: "100"}}); + expect(target).to.be('10 * 100'); + }); + + }); }); diff --git a/public/app/features/templating/specs/variable_srv_init_specs.ts b/public/app/features/templating/specs/variable_srv_init_specs.ts index 533c70dfc25..5176c24dec5 100644 --- a/public/app/features/templating/specs/variable_srv_init_specs.ts +++ b/public/app/features/templating/specs/variable_srv_init_specs.ts @@ -12,6 +12,9 @@ describe('VariableSrv init', function() { beforeEach(angularMocks.module('grafana.core')); beforeEach(angularMocks.module('grafana.controllers')); beforeEach(angularMocks.module('grafana.services')); + beforeEach(angularMocks.module(function($compileProvider) { + $compileProvider.preAssignBindingsEnabled(true); + })); beforeEach(ctx.providePhase(['datasourceSrv', 'timeSrv', 'templateSrv', '$location'])); beforeEach(angularMocks.inject(($rootScope, $q, $location, $injector) => { diff --git a/public/app/features/templating/templateSrv.js b/public/app/features/templating/templateSrv.js index a41f5a9d0f3..f47afc81574 100644 --- a/public/app/features/templating/templateSrv.js +++ b/public/app/features/templating/templateSrv.js @@ -17,6 +17,11 @@ function (angular, _, kbn) { this._grafanaVariables = {}; this._adhocVariables = {}; + // default built ins + this._builtIns = {}; + this._builtIns['__interval'] = {text: '1s', value: '1s'}; + this._builtIns['__interval_ms'] = {text: '100', value: '100'}; + this.init = function(variables) { this.variables = variables; this.updateTemplateData(); @@ -42,6 +47,7 @@ function (angular, _, kbn) { this._index[variable.name] = variable; } + }; this.variableInitialized = function(variable) { @@ -97,13 +103,16 @@ function (angular, _, kbn) { return value.join('|'); } case "distributed": { - return this.distributeVariable(value, variable.name); - } - default: { if (typeof value === 'string') { return value; } - return '{' + value.join(',') + '}'; + return this.distributeVariable(value, variable.name); + } + default: { + if (_.isArray(value)) { + return '{' + value.join(',') + '}'; + } + return value; } } }; @@ -132,7 +141,7 @@ function (angular, _, kbn) { str = _.escape(str); this._regex.lastIndex = 0; return str.replace(this._regex, function(match, g1, g2) { - if (self._index[g1 || g2]) { + if (self._index[g1 || g2] || self._builtIns[g1 || g2]) { return '' + match + ''; } return match; diff --git a/public/app/partials/dashboard.html b/public/app/partials/dashboard.html index c7cc6fb440d..7dd925c3f02 100644 --- a/public/app/partials/dashboard.html +++ b/public/app/partials/dashboard.html @@ -12,13 +12,13 @@ -
    -
    -
    - - ADD ROW - -
    -
    +
    +
    + + ADD ROW + +
    +
    +
    diff --git a/public/app/plugins/app/testdata/dashboards/graph_last_1h.json b/public/app/plugins/app/testdata/dashboards/graph_last_1h.json index c314feb56ad..c56d9e9216f 100644 --- a/public/app/plugins/app/testdata/dashboards/graph_last_1h.json +++ b/public/app/plugins/app/testdata/dashboards/graph_last_1h.json @@ -1,58 +1,17 @@ { - "revision": 6, - "title": "TestData - Graph Panel Last 1h", - "tags": [ - "grafana-test" - ], - "style": "dark", - "timezone": "browser", - "editable": true, - "sharedCrosshair": false, - "hideControls": false, - "time": { - "from": "2016-11-16T16:59:38.294Z", - "to": "2016-11-16T17:09:01.532Z" - }, - "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "templating": { - "list": [] - }, "annotations": { "list": [] }, - "refresh": false, - "schemaVersion": 13, - "version": 4, - "links": [], + "editable": true, "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "links": [], + "refresh": false, + "revision": 8, "rows": [ { "collapse": false, - "editable": true, "height": "250px", "panels": [ { @@ -63,7 +22,6 @@ "error": false, "fill": 1, "id": 1, - "isNew": true, "legend": { "avg": false, "current": false, @@ -137,7 +95,6 @@ "error": false, "fill": 1, "id": 2, - "isNew": true, "legend": { "avg": false, "current": false, @@ -211,7 +168,6 @@ "error": false, "fill": 1, "id": 3, - "isNew": true, "legend": { "avg": false, "current": false, @@ -278,17 +234,15 @@ ] } ], - "title": "New row", - "showTitle": false, - "titleSize": "h6", - "isNew": false, "repeat": null, + "repeatIteration": null, "repeatRowId": null, - "repeatIteration": null + "showTitle": false, + "title": "New row", + "titleSize": "h6" }, { "collapse": false, - "editable": true, "height": "250px", "panels": [ { @@ -299,7 +253,6 @@ "error": false, "fill": 1, "id": 4, - "isNew": true, "legend": { "avg": false, "current": false, @@ -370,7 +323,6 @@ "editable": true, "error": false, "id": 6, - "isNew": true, "links": [], "mode": "markdown", "span": 4, @@ -378,17 +330,15 @@ "type": "text" } ], - "title": "New row", - "showTitle": false, - "titleSize": "h6", - "isNew": false, "repeat": null, + "repeatIteration": null, "repeatRowId": null, - "repeatIteration": null + "showTitle": false, + "title": "New row", + "titleSize": "h6" }, { "collapse": false, - "editable": true, "height": 336, "panels": [ { @@ -399,7 +349,6 @@ "error": false, "fill": 1, "id": 5, - "isNew": true, "legend": { "avg": false, "current": false, @@ -481,7 +430,6 @@ "editable": true, "error": false, "id": 7, - "isNew": true, "links": [], "mode": "markdown", "span": 4, @@ -489,17 +437,15 @@ "type": "text" } ], - "title": "New row", - "showTitle": false, - "titleSize": "h6", - "isNew": false, "repeat": null, + "repeatIteration": null, "repeatRowId": null, - "repeatIteration": null + "showTitle": false, + "title": "New row", + "titleSize": "h6" }, { "collapse": false, - "editable": true, "height": "250px", "panels": [ { @@ -510,7 +456,6 @@ "error": false, "fill": 1, "id": 8, - "isNew": true, "legend": { "avg": false, "current": false, @@ -584,7 +529,6 @@ "error": false, "fill": 1, "id": 10, - "isNew": true, "legend": { "avg": false, "current": false, @@ -655,7 +599,6 @@ "editable": true, "error": false, "id": 13, - "isNew": true, "links": [], "mode": "markdown", "span": 4, @@ -663,17 +606,16 @@ "type": "text" } ], - "title": "New row", - "showTitle": false, - "titleSize": "h6", - "isNew": false, "repeat": null, + "repeatIteration": null, "repeatRowId": null, - "repeatIteration": null + "showTitle": false, + "title": "New row", + "titleSize": "h6" }, { - "isNew": false, - "title": "Dashboard Row", + "collapse": false, + "height": 250, "panels": [ { "aliasColors": {}, @@ -683,7 +625,6 @@ "error": false, "fill": 1, "id": 9, - "isNew": true, "legend": { "avg": false, "current": false, @@ -776,7 +717,6 @@ "editable": true, "error": false, "id": 14, - "isNew": true, "links": [], "mode": "markdown", "span": 4, @@ -784,17 +724,16 @@ "type": "text" } ], - "showTitle": false, - "titleSize": "h6", - "height": 250, "repeat": null, - "repeatRowId": null, "repeatIteration": null, - "collapse": false + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" }, { - "isNew": false, - "title": "Dashboard Row", + "collapse": false, + "height": 250, "panels": [ { "aliasColors": {}, @@ -804,7 +743,6 @@ "error": false, "fill": 1, "id": 12, - "isNew": true, "legend": { "avg": false, "current": false, @@ -833,12 +771,12 @@ "steppedLine": false, "targets": [ { + "alias": "", "hide": false, "refId": "B", "scenarioId": "csv_metric_values", "stringInput": "1,20,40,null,null,null,null,null,null,100,10,10,20,30,40,10", - "target": "", - "alias": "" + "target": "" }, { "alias": "", @@ -898,7 +836,6 @@ "editable": true, "error": false, "id": 15, - "isNew": true, "links": [], "mode": "markdown", "span": 4, @@ -906,13 +843,606 @@ "type": "text" } ], - "showTitle": false, - "titleSize": "h6", - "height": 250, "repeat": null, - "repeatRowId": null, "repeatIteration": null, - "collapse": false + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "datasource": "Grafana TestData", + "decimals": 3, + "fill": 1, + "id": 20, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table Single Series Should Take Minium Height", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "datasource": "Grafana TestData", + "decimals": 3, + "fill": 1, + "id": 16, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table No Scroll Visible", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "datasource": "Grafana TestData", + "decimals": 3, + "fill": 1, + "id": 17, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "E", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "F", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "G", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "H", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "I", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "J", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table Should Scroll", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "datasource": "Grafana TestData", + "decimals": 3, + "fill": 1, + "id": 18, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table No Scroll Visible", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "datasource": "Grafana TestData", + "decimals": 3, + "fill": 1, + "id": 19, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "rightSide": true, + "show": true, + "total": true, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "D", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "E", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "F", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "G", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "H", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "I", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "J", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "K", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + }, + { + "refId": "L", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0", + "target": "" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Legend Table No Scroll Visible", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": false, + "title": "Dashboard Row", + "titleSize": "h6" } - ] + ], + "schemaVersion": 14, + "style": "dark", + "tags": [ + "grafana-test" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "TestData - Graph Panel Last 1h", + "version": 2 } diff --git a/public/app/plugins/app/testdata/module.ts b/public/app/plugins/app/testdata/module.ts index dee1679637a..6fb0e0ba98c 100644 --- a/public/app/plugins/app/testdata/module.ts +++ b/public/app/plugins/app/testdata/module.ts @@ -5,6 +5,7 @@ export class ConfigCtrl { appEditCtrl: any; + /** @ngInject **/ constructor(private backendSrv) { this.appEditCtrl.setPreUpdateHook(this.initDatasource.bind(this)); } diff --git a/public/app/plugins/app/testdata/plugin.json b/public/app/plugins/app/testdata/plugin.json index f8723f95a5f..1d6264c54c1 100644 --- a/public/app/plugins/app/testdata/plugin.json +++ b/public/app/plugins/app/testdata/plugin.json @@ -9,7 +9,7 @@ "name": "Grafana Project", "url": "http://grafana.org" }, - "version": "1.0.15", + "version": "1.0.17", "updated": "2016-09-26" }, diff --git a/public/app/plugins/datasource/cloudwatch/config_ctrl.ts b/public/app/plugins/datasource/cloudwatch/config_ctrl.ts index b04533bd20c..ee76b0d15fa 100644 --- a/public/app/plugins/datasource/cloudwatch/config_ctrl.ts +++ b/public/app/plugins/datasource/cloudwatch/config_ctrl.ts @@ -7,8 +7,8 @@ export class CloudWatchConfigCtrl { static templateUrl = 'partials/config.html'; current: any; - accessKeyExist: boolean = false; - secretKeyExist: boolean = false; + accessKeyExist = false; + secretKeyExist = false; /** @ngInject */ constructor($scope) { diff --git a/public/app/plugins/datasource/cloudwatch/partials/config.html b/public/app/plugins/datasource/cloudwatch/partials/config.html index c67fc44a60b..c4b05f6acf9 100644 --- a/public/app/plugins/datasource/cloudwatch/partials/config.html +++ b/public/app/plugins/datasource/cloudwatch/partials/config.html @@ -39,7 +39,7 @@
    - + Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region. diff --git a/public/app/plugins/datasource/elasticsearch/bucket_agg.js b/public/app/plugins/datasource/elasticsearch/bucket_agg.js index 43ef4f91ab7..28f1df08251 100644 --- a/public/app/plugins/datasource/elasticsearch/bucket_agg.js +++ b/public/app/plugins/datasource/elasticsearch/bucket_agg.js @@ -73,7 +73,7 @@ function (angular, _, queryDef) { $scope.validateModel = function() { $scope.index = _.indexOf(bucketAggs, $scope.agg); $scope.isFirst = $scope.index === 0; - $scope.isLast = $scope.index === bucketAggs.length - 1; + $scope.bucketAggCount = bucketAggs.length; var settingsLinkText = ""; var settings = $scope.agg.settings || {}; @@ -82,12 +82,17 @@ function (angular, _, queryDef) { case 'terms': { settings.order = settings.order || "asc"; settings.size = settings.size || "10"; + settings.min_doc_count = settings.min_doc_count || 0; settings.orderBy = settings.orderBy || "_term"; if (settings.size !== '0') { settingsLinkText = queryDef.describeOrder(settings.order) + ' ' + settings.size + ', '; } + if (settings.min_doc_count > 0) { + settingsLinkText += 'Min Doc Count: ' + settings.min_doc_count + ', '; + } + settingsLinkText += 'Order by: ' + queryDef.describeOrderBy(settings.orderBy, $scope.target); if (settings.size === '0') { diff --git a/public/app/plugins/datasource/elasticsearch/datasource.js b/public/app/plugins/datasource/elasticsearch/datasource.js index 923b3dfb34c..4377d634982 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.js +++ b/public/app/plugins/datasource/elasticsearch/datasource.js @@ -87,7 +87,7 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes var queryInterpolated = templateSrv.replace(queryString, {}, 'lucene'); var query = { "bool": { - "must": [ + "filter": [ { "range": range }, { "query_string": { @@ -168,6 +168,7 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes }; this.testDatasource = function() { + timeSrv.setTime({ from: 'now-1m', to: 'now' }); return this._get('/_stats').then(function() { return { status: "success", message: "Data source is working", title: "Success" }; }, function(err) { @@ -197,15 +198,9 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes target = options.targets[i]; if (target.hide) {continue;} - var queryObj = this.queryBuilder.build(target, adhocFilters); + var queryString = templateSrv.replace(target.query || '*', options.scopedVars, 'lucene'); + var queryObj = this.queryBuilder.build(target, adhocFilters, queryString); var esQuery = angular.toJson(queryObj); - var luceneQuery = target.query || '*'; - luceneQuery = templateSrv.replace(luceneQuery, options.scopedVars, 'lucene'); - luceneQuery = angular.toJson(luceneQuery); - - // remove inner quotes - luceneQuery = luceneQuery.substr(1, luceneQuery.length - 2); - esQuery = esQuery.replace("$lucene_query", luceneQuery); var searchType = (queryObj.size === 0 && this.esVersion < 5) ? 'count' : 'query_then_fetch'; var header = this.getQueryHeader(searchType, options.range.from, options.range.to); @@ -219,7 +214,6 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes return $q.when([]); } - payload = payload.replace(/\$interval/g, options.interval); payload = payload.replace(/\$timeFrom/g, options.range.from.valueOf()); payload = payload.replace(/\$timeTo/g, options.range.to.valueOf()); payload = templateSrv.replace(payload, options.scopedVars); @@ -231,6 +225,7 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes this.getFields = function(query) { return this._get('/_mapping').then(function(result) { + var typeMap = { 'float': 'number', 'double': 'number', @@ -238,12 +233,28 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes 'long': 'number', 'date': 'date', 'string': 'string', + 'text': 'string', + 'scaled_float': 'number', 'nested': 'nested' }; + function shouldAddField(obj, key, query) { + if (key[0] === '_') { + return false; + } + + if (!query.type) { + return true; + } + + // equal query type filter, or via typemap translation + return query.type === obj.type || query.type === typeMap[obj.type]; + } + // Store subfield names: [system, process, cpu, total] -> system.process.cpu.total var fieldNameParts = []; var fields = {}; + function getFieldsRecursively(obj) { for (var key in obj) { var subObj = obj[key]; @@ -256,10 +267,7 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes var fieldName = fieldNameParts.concat(key).join('.'); // Hide meta-fields and check field type - if (key[0] !== '_' && - (!query.type || - query.type && typeMap[subObj.type] === query.type)) { - + if (shouldAddField(subObj, key, query)) { fields[fieldName] = { text: fieldName, type: subObj.type diff --git a/public/app/plugins/datasource/elasticsearch/elastic_response.js b/public/app/plugins/datasource/elasticsearch/elastic_response.js index 4e82a280024..27285852e33 100644 --- a/public/app/plugins/datasource/elasticsearch/elastic_response.js +++ b/public/app/plugins/datasource/elasticsearch/elastic_response.js @@ -166,7 +166,7 @@ function (_, queryDef) { for (var nameIndex in esAgg.buckets) { bucket = esAgg.buckets[nameIndex]; props = _.clone(props); - if (bucket.key) { + if (bucket.key !== void 0) { props[aggDef.field] = bucket.key; } else { props["filter"] = nameIndex; @@ -199,7 +199,7 @@ function (_, queryDef) { var group = g1 || g2; if (group.indexOf('term ') === 0) { return series.props[group.substring(5)]; } - if (series.props[group]) { return series.props[group]; } + if (series.props[group] !== void 0) { return series.props[group]; } if (group === 'metric') { return metricName; } if (group === 'field') { return series.field; } diff --git a/public/app/plugins/datasource/elasticsearch/metric_agg.js b/public/app/plugins/datasource/elasticsearch/metric_agg.js index 845794d79f3..9971c084882 100644 --- a/public/app/plugins/datasource/elasticsearch/metric_agg.js +++ b/public/app/plugins/datasource/elasticsearch/metric_agg.js @@ -29,6 +29,7 @@ function (angular, _, queryDef) { $scope.metricAggTypes = queryDef.getMetricAggTypes($scope.esVersion); $scope.extendedStats = queryDef.extendedStats; $scope.pipelineAggOptions = []; + $scope.modelSettingsValues = {}; $scope.init = function() { $scope.agg = metricAggs[$scope.index]; @@ -95,6 +96,12 @@ function (angular, _, queryDef) { $scope.settingsLinkText = 'Stats: ' + stats.join(', '); break; } + case 'moving_avg': { + $scope.movingAvgModelTypes = queryDef.movingAvgModelOptions; + $scope.modelSettings = queryDef.getMovingAvgSettings($scope.agg.settings.model, true); + $scope.updateMovingAvgModelSettings(); + break; + } case 'raw_document': { $scope.target.metrics = [$scope.agg]; $scope.target.bucketAggs = []; @@ -127,6 +134,25 @@ function (angular, _, queryDef) { $scope.onChange(); }; + $scope.updateMovingAvgModelSettings = function () { + var modelSettingsKeys = []; + var modelSettings = queryDef.getMovingAvgSettings($scope.agg.settings.model, false); + for (var i=0; i < modelSettings.length; i++) { + modelSettingsKeys.push(modelSettings[i].value); + } + + for (var key in $scope.agg.settings.settings) { + if (($scope.agg.settings.settings[key] === null) || (modelSettingsKeys.indexOf(key) === -1)) { + delete $scope.agg.settings.settings[key]; + } + } + }; + + $scope.onChangeClearInternal = function() { + delete $scope.agg.settings.minimize; + $scope.onChange(); + }; + $scope.onTypeChange = function() { $scope.agg.settings = {}; $scope.agg.meta = {}; diff --git a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html b/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html index e3e6cd6f5ff..562f0b86e56 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html +++ b/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html @@ -23,7 +23,7 @@ -
    @@ -61,6 +61,10 @@
    +
    + + +
    diff --git a/public/app/plugins/datasource/elasticsearch/partials/metric_agg.html b/public/app/plugins/datasource/elasticsearch/partials/metric_agg.html index 74cf23516cd..8c25c2d5347 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/metric_agg.html +++ b/public/app/plugins/datasource/elasticsearch/partials/metric_agg.html @@ -37,52 +37,62 @@
    -
    -
    - - -
    +
    +
    + + +
    -
    - - -
    +
    + + +
    -
    - - -
    +
    + + +
    -
    - - -
    -
    - - -
    +
    + + +
    -
    - + + +
    -
    - - -
    -
    +
    + + +
    -
    - - -
    +
    + + +
    -
    +
    + + +
    + + +
    +
    + +
    + + +
    + +
    - -
    - Font Size - - - -
    diff --git a/public/app/plugins/panel/text/module.ts b/public/app/plugins/panel/text/module.ts index c817c184380..8c321e2e0c3 100644 --- a/public/app/plugins/panel/text/module.ts +++ b/public/app/plugins/panel/text/module.ts @@ -28,6 +28,10 @@ export class TextPanelCtrl extends PanelCtrl { onInitEditMode() { this.addEditorTab('Options', 'public/app/plugins/panel/text/editor.html'); this.editorTabIndex = 1; + + if (this.panel.mode === 'text') { + this.panel.mode = 'markdown'; + } } onRefresh() { @@ -39,8 +43,6 @@ export class TextPanelCtrl extends PanelCtrl { this.renderMarkdown(this.panel.content); } else if (this.panel.mode === 'html') { this.updateContent(this.panel.content); - } else if (this.panel.mode === 'text') { - this.renderText(this.panel.content); } this.renderingCompleted(); } diff --git a/public/fonts/grafana-icons.eot b/public/fonts/grafana-icons.eot index 88bc142e205..62c2727deb2 100755 Binary files a/public/fonts/grafana-icons.eot and b/public/fonts/grafana-icons.eot differ diff --git a/public/fonts/grafana-icons.svg b/public/fonts/grafana-icons.svg index 9006ea24078..e8cbc10d72c 100755 --- a/public/fonts/grafana-icons.svg +++ b/public/fonts/grafana-icons.svg @@ -59,4 +59,5 @@ + \ No newline at end of file diff --git a/public/fonts/grafana-icons.ttf b/public/fonts/grafana-icons.ttf index 50b2a0303f6..642406716a5 100755 Binary files a/public/fonts/grafana-icons.ttf and b/public/fonts/grafana-icons.ttf differ diff --git a/public/fonts/grafana-icons.woff b/public/fonts/grafana-icons.woff index a4dde357152..edcc0896f72 100755 Binary files a/public/fonts/grafana-icons.woff and b/public/fonts/grafana-icons.woff differ diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss index 95dcefc257c..8ecad9e3287 100644 --- a/public/sass/_variables.dark.scss +++ b/public/sass/_variables.dark.scss @@ -33,7 +33,7 @@ $yellow: #ECBB13; $pink: #FF4444; $purple: #9933CC; $variable: #32D1DF; -$orange: #dF7518; +$orange: #eb7b18; $brand-primary: $orange; $brand-success: $green; diff --git a/public/sass/base/_grafana_icons.scss b/public/sass/base/_grafana_icons.scss index 65fe0e23871..42834e704b3 100644 --- a/public/sass/base/_grafana_icons.scss +++ b/public/sass/base/_grafana_icons.scss @@ -143,6 +143,9 @@ .icon-gf-bulk_action:before { content: "\e61c"; } +.icon-gf-grabber:before { + content: "\e90b"; +} .icon-gf-users:before { content: "\e622"; } diff --git a/public/sass/components/_panel_alertlist.scss b/public/sass/components/_panel_alertlist.scss index 41f63b61bd4..c8e8354f4d7 100644 --- a/public/sass/components/_panel_alertlist.scss +++ b/public/sass/components/_panel_alertlist.scss @@ -1,3 +1,3 @@ .panel-alert-list { - overflow-y: scroll; + overflow-y: auto; } diff --git a/public/sass/components/_panel_graph.scss b/public/sass/components/_panel_graph.scss index d158f7b1043..9e919072dce 100644 --- a/public/sass/components/_panel_graph.scss +++ b/public/sass/components/_panel_graph.scss @@ -85,8 +85,11 @@ } .graph-legend-table { - overflow-y: auto; - overflow-x: hidden; + tbody { + display: block; + overflow-y: auto; + overflow-x: hidden; + } .graph-legend-series { display: table-row; @@ -297,16 +300,18 @@ .left-yaxis-label { top: 50%; - left: -5px; - transform: rotate(-90deg); - transform-origin: left top; + left: 0; + transform: translateX(-50%) translateY(-50%) rotate(-90deg); + // this is needed for phantomsjs 2.1 + -webkit-transform: translateX(-50%) translateY(-50%) rotate(-90deg); } .right-yaxis-label { top: 50%; - right: -5px; - transform: rotate(90deg); - transform-origin: right top; + right: 0; + transform: translateX(50%) translateY(-50%) rotate(90deg); + // this is needed for phantomsjs 2.1 + -webkit-transform: translateX(50%) translateY(-50%) rotate(90deg); } .axisLabel { diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index 2eeb34ec0b4..d8c3942bc24 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -68,9 +68,9 @@ } .search-item { + word-wrap: break-word; display: block; padding: 3px 10px; - white-space: nowrap; background-color: $grafanaListBackground; margin-bottom: 4px; diff --git a/public/sass/components/_tabbed_view.scss b/public/sass/components/_tabbed_view.scss index e72252330a1..751469f9463 100644 --- a/public/sass/components/_tabbed_view.scss +++ b/public/sass/components/_tabbed_view.scss @@ -9,7 +9,7 @@ padding: 0; .tabbed-view-header { - padding: 0; +/* padding: 0; */ background-color: $body-bg; padding: 1.5em 1rem 0 1rem; } diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss index 548d5228fd6..beaf463fa8f 100644 --- a/public/sass/pages/_dashboard.scss +++ b/public/sass/pages/_dashboard.scss @@ -57,6 +57,7 @@ div.flot-text { min-height: 9px; padding-top: 4px; cursor: pointer; + word-wrap: break-word; } .panel-title { @@ -68,16 +69,6 @@ div.flot-text { display: block; } -.panel-links-btn { - margin-left: 10px; - display: none; -} - -.panel-help-text { - margin-left: 10px; - display: none; -} - .panel-loading { position:absolute; top: -3px; @@ -89,6 +80,23 @@ div.flot-text { text-align: center; } + +.panel-info-corner-inner { + width: 0; + height: 0; + position: absolute; + left: 0; + bottom: 0; +} + +@mixin panel-corner-color($corner-bg) { + .panel-info-corner-inner { + border-left: 27px solid $corner-bg; + border-right: none; + border-bottom: 27px solid transparent; + } +} + .panel-info-corner { color: $text-muted; cursor: pointer; @@ -105,11 +113,12 @@ div.flot-text { top: -4px; left: -6px; font-size: 75%; + z-index: 1000; } &--info { display: block; - background: lighten($panel-bg, 4%); + @include panel-corner-color(lighten($panel-bg, 4%)); .fa:before { content: "\f129"; } @@ -117,7 +126,7 @@ div.flot-text { &--links { display: block; - background: lighten($panel-bg, 4%); + @include panel-corner-color(lighten($panel-bg, 4%)); .fa { left: -5px; } @@ -129,24 +138,13 @@ div.flot-text { &--error { display: block; color: $text-color; - background: $errorBackground !important; + @include panel-corner-color($errorBackground); .fa:before { content: "\f12a"; } } } -.panel-info-corner-inner { - width: 0; - height: 0; - position: absolute; - border-left: 27px solid transparent; - border-right: 0px solid transparent; - border-bottom: 26px solid $panel-bg; - left: 0; - bottom: 0; -} - .panel-full-edit { margin-top: 20px; margin-bottom: 20px; @@ -231,17 +229,17 @@ div.flot-text { .resize-panel-handle { cursor: nwse-resize; position: absolute; + font-size: 10px; bottom: 0; right: 0; width: 15px; height: 15px; display: block; color: $text-color-faint; - overflow: hidden; &:before { left: initial; - right: -5px; + right: -1px; bottom: 0px; position: absolute; } diff --git a/public/test/specs/helpers.js b/public/test/specs/helpers.js index 424b190b6dd..53c761aaa6c 100644 --- a/public/test/specs/helpers.js +++ b/public/test/specs/helpers.js @@ -92,7 +92,6 @@ define([ self.timeSrv = new TimeSrvStub(); self.datasourceSrv = {}; self.backendSrv = {}; - self.$location = {}; self.$routeParams = {}; this.providePhase = function(mocks) { @@ -104,10 +103,11 @@ define([ }; this.createService = function(name) { - return inject(function($q, $rootScope, $httpBackend, $injector) { + return inject(function($q, $rootScope, $httpBackend, $injector, $location) { self.$q = $q; self.$rootScope = $rootScope; self.$httpBackend = $httpBackend; + self.$location = $location; self.$rootScope.onAppEvent = function() {}; self.$rootScope.appEvent = function() {}; @@ -162,6 +162,7 @@ define([ this.fillVariableValuesForUrl = function() {}; this.updateTemplateData = function() { }; this.variableExists = function() { return false; }; + this.variableInitialized = function() { }; this.highlightVariablesAsHtml = function(str) { return str; }; this.setGrafanaVariable = function(name, value) { this.data[name] = value; diff --git a/public/test/specs/linkSrv-specs.js b/public/test/specs/linkSrv-specs.js index 9ecff29bc77..309fdb07076 100644 --- a/public/test/specs/linkSrv-specs.js +++ b/public/test/specs/linkSrv-specs.js @@ -1,5 +1,6 @@ define([ 'lodash', + 'app/features/dashboard/all', 'app/features/panellinks/linkSrv' ], function(_) { 'use strict'; diff --git a/public/test/specs/shareModalCtrl-specs.js b/public/test/specs/shareModalCtrl-specs.js index 7e2df968e97..4bd3760ad5e 100644 --- a/public/test/specs/shareModalCtrl-specs.js +++ b/public/test/specs/shareModalCtrl-specs.js @@ -16,6 +16,9 @@ define([ beforeEach(module('grafana.controllers')); beforeEach(module('grafana.services')); + beforeEach(module(function($compileProvider) { + $compileProvider.preAssignBindingsEnabled(true); + })); beforeEach(ctx.providePhase()); @@ -28,7 +31,7 @@ define([ ctx.scope.panel = { id: 22 }; ctx.scope.init(); - expect(ctx.scope.shareUrl).to.be('http://server/#/test?from=1000&to=2000&panelId=22&fullscreen'); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&panelId=22&fullscreen'); }); it('should generate render url', function() { @@ -38,8 +41,8 @@ define([ ctx.scope.init(); var base = 'http://dashboards.grafana.com/render/dashboard-solo/db/my-dash'; - var params = '?from=1000&to=2000&panelId=22&width=1000&height=500'; - expect(ctx.scope.imageUrl).to.be(base + params); + var params = '?from=1000&to=2000&panelId=22&width=1000&height=500&tz=UTC'; + expect(ctx.scope.imageUrl).to.contain(base + params); }); it('should remove panel id when no panel in scope', function() { @@ -48,7 +51,7 @@ define([ ctx.scope.panel = null; ctx.scope.init(); - expect(ctx.scope.shareUrl).to.be('http://server/#/test?from=1000&to=2000'); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000'); }); it('should add theme when specified', function() { @@ -57,7 +60,7 @@ define([ ctx.scope.panel = null; ctx.scope.init(); - expect(ctx.scope.shareUrl).to.be('http://server/#/test?from=1000&to=2000&theme=light'); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&theme=light'); }); it('should include template variables in url', function() { @@ -70,7 +73,7 @@ define([ }; ctx.scope.buildUrl(); - expect(ctx.scope.shareUrl).to.be('http://server/#/test?from=1000&to=2000&var-app=mupp&var-server=srv-01'); + expect(ctx.scope.shareUrl).to.be('http://server/#!/test?from=1000&to=2000&var-app=mupp&var-server=srv-01'); }); }); diff --git a/public/test/specs/time_srv_specs.js b/public/test/specs/time_srv_specs.js deleted file mode 100644 index f95f2daecd2..00000000000 --- a/public/test/specs/time_srv_specs.js +++ /dev/null @@ -1,120 +0,0 @@ -define([ - 'test/mocks/dashboard-mock', - 'test/specs/helpers', - 'lodash', - 'moment', - 'app/core/services/timer', - 'app/features/dashboard/timeSrv' -], function(dashboardMock, helpers, _, moment) { - 'use strict'; - - describe('timeSrv', function() { - var ctx = new helpers.ServiceTestContext(); - var _dashboard; - - beforeEach(module('grafana.core')); - beforeEach(module('grafana.services')); - beforeEach(ctx.providePhase(['$routeParams'])); - beforeEach(ctx.createService('timeSrv')); - - beforeEach(function() { - _dashboard = dashboardMock.create(); - ctx.service.init(_dashboard); - }); - - describe('timeRange', function() { - it('should return unparsed when parse is false', function() { - ctx.service.setTime({from: 'now', to: 'now-1h' }); - var time = ctx.service.timeRange(); - expect(time.raw.from).to.be('now'); - expect(time.raw.to).to.be('now-1h'); - }); - - it('should return parsed when parse is true', function() { - ctx.service.setTime({from: 'now', to: 'now-1h' }); - var time = ctx.service.timeRange(); - expect(moment.isMoment(time.from)).to.be(true); - expect(moment.isMoment(time.to)).to.be(true); - }); - }); - - describe('init time from url', function() { - it('should handle relative times', function() { - ctx.$routeParams.from = 'now-2d'; - ctx.$routeParams.to = 'now'; - ctx.service.init(_dashboard); - var time = ctx.service.timeRange(); - expect(time.raw.from).to.be('now-2d'); - expect(time.raw.to).to.be('now'); - }); - - it('should handle formated dates', function() { - ctx.$routeParams.from = '20140410T052010'; - ctx.$routeParams.to = '20140520T031022'; - ctx.service.init(_dashboard); - var time = ctx.service.timeRange(true); - expect(time.from.valueOf()).to.equal(new Date("2014-04-10T05:20:10Z").getTime()); - expect(time.to.valueOf()).to.equal(new Date("2014-05-20T03:10:22Z").getTime()); - }); - - it('should handle formated dates without time', function() { - ctx.$routeParams.from = '20140410'; - ctx.$routeParams.to = '20140520'; - ctx.service.init(_dashboard); - var time = ctx.service.timeRange(true); - expect(time.from.valueOf()).to.equal(new Date("2014-04-10T00:00:00Z").getTime()); - expect(time.to.valueOf()).to.equal(new Date("2014-05-20T00:00:00Z").getTime()); - }); - - it('should handle epochs', function() { - ctx.$routeParams.from = '1410337646373'; - ctx.$routeParams.to = '1410337665699'; - ctx.service.init(_dashboard); - var time = ctx.service.timeRange(true); - expect(time.from.valueOf()).to.equal(1410337646373); - expect(time.to.valueOf()).to.equal(1410337665699); - }); - - it('should handle bad dates', function() { - ctx.$routeParams.from = '20151126T00010%3C%2Fp%3E%3Cspan%20class'; - ctx.$routeParams.to = 'now'; - _dashboard.time.from = 'now-6h'; - ctx.service.init(_dashboard); - expect(ctx.service.time.from).to.equal('now-6h'); - expect(ctx.service.time.to).to.equal('now'); - }); - }); - - describe('setTime', function() { - it('should return disable refresh if refresh is disabled for any range', function() { - _dashboard.refresh = false; - - ctx.service.setTime({from: '2011-01-01', to: '2015-01-01' }); - expect(_dashboard.refresh).to.be(false); - }); - - it('should restore refresh for absolute time range', function() { - _dashboard.refresh = '30s'; - - ctx.service.setTime({from: '2011-01-01', to: '2015-01-01' }); - expect(_dashboard.refresh).to.be('30s'); - }); - - it('should restore refresh after relative time range is set', function() { - _dashboard.refresh = '10s'; - ctx.service.setTime({from: moment([2011,1,1]), to: moment([2015,1,1])}); - expect(_dashboard.refresh).to.be(false); - ctx.service.setTime({from: '2011-01-01', to: 'now' }); - expect(_dashboard.refresh).to.be('10s'); - }); - - it('should keep refresh after relative time range is changed and now delay exists', function() { - _dashboard.refresh = '10s'; - ctx.service.setTime({from: 'now-1h', to: 'now-10s' }); - expect(_dashboard.refresh).to.be('10s'); - }); - }); - - }); - -}); diff --git a/public/test/specs/value_select_dropdown_specs.js b/public/test/specs/value_select_dropdown_specs.js index 2bca4aaaf5c..0672ec6eedf 100644 --- a/public/test/specs/value_select_dropdown_specs.js +++ b/public/test/specs/value_select_dropdown_specs.js @@ -9,22 +9,26 @@ function () { var ctrl; var tagValuesMap = {}; var rootScope; + var q; beforeEach(module('grafana.core')); beforeEach(inject(function($controller, $rootScope, $q, $httpBackend) { rootScope = $rootScope; + q = $q; scope = $rootScope.$new(); ctrl = $controller('ValueSelectDropdownCtrl', {$scope: scope}); - ctrl.getValuesForTag = function(obj) { - return $q.when(tagValuesMap[obj.tagKey]); - }; ctrl.onUpdated = sinon.spy(); $httpBackend.when('GET', /\.html$/).respond(''); })); describe("Given simple variable", function() { beforeEach(function() { - ctrl.variable = {current: {text: 'hej', value: 'hej' }}; + ctrl.variable = { + current: {text: 'hej', value: 'hej' }, + getValuesForTag: function(key) { + return q.when(tagValuesMap[key]); + }, + }; ctrl.init(); }); @@ -43,6 +47,9 @@ function () { {text: 'server-3', value: 'server-3'}, ], tags: ["key1", "key2", "key3"], + getValuesForTag: function(key) { + return q.when(tagValuesMap[key]); + }, multi: true }; tagValuesMap.key1 = ['server-1', 'server-3']; @@ -145,6 +152,9 @@ function () { {text: 'server-3', value: 'server-3'}, ], tags: ["key1", "key2", "key3"], + getValuesForTag: function(key) { + return q.when(tagValuesMap[key]); + }, multi: true }; ctrl.init(); diff --git a/public/vendor/angular-mocks/.bower.json b/public/vendor/angular-mocks/.bower.json index 119ddaab59a..839e25a0f23 100644 --- a/public/vendor/angular-mocks/.bower.json +++ b/public/vendor/angular-mocks/.bower.json @@ -1,20 +1,20 @@ { "name": "angular-mocks", - "version": "1.5.8", + "version": "1.6.1", "license": "MIT", "main": "./angular-mocks.js", "ignore": [], "dependencies": { - "angular": "1.5.8" + "angular": "1.6.1" }, "homepage": "https://github.com/angular/bower-angular-mocks", - "_release": "1.5.8", + "_release": "1.6.1", "_resolution": { "type": "version", - "tag": "v1.5.8", - "commit": "482eefcf6b03057c5fcddb9750e460f458ee3487" + "tag": "v1.6.1", + "commit": "d8ac5a2016c9714b7c87284d21a34648036e8eea" }, "_source": "https://github.com/angular/bower-angular-mocks.git", - "_target": "1.5.8", + "_target": "1.6.1", "_originalSource": "angular-mocks" } \ No newline at end of file diff --git a/public/vendor/angular-mocks/angular-mocks.js b/public/vendor/angular-mocks/angular-mocks.js index 42f19b7aeab..41f67ca858f 100644 --- a/public/vendor/angular-mocks/angular-mocks.js +++ b/public/vendor/angular-mocks/angular-mocks.js @@ -1,5 +1,5 @@ /** - * @license AngularJS v1.5.8 + * @license AngularJS v1.6.1 * (c) 2010-2016 Google, Inc. http://angularjs.org * License: MIT */ @@ -40,7 +40,7 @@ angular.mock.$Browser = function() { var self = this; this.isMock = true; - self.$$url = "http://server/"; + self.$$url = 'http://server/'; self.$$lastUrl = self.$$url; // used by url polling fn self.pollFns = []; @@ -252,19 +252,19 @@ angular.mock.$ExceptionHandlerProvider = function() { case 'rethrow': var errors = []; handler = function(e) { - if (arguments.length == 1) { + if (arguments.length === 1) { errors.push(e); } else { errors.push([].slice.call(arguments, 0)); } - if (mode === "rethrow") { + if (mode === 'rethrow') { throw e; } }; handler.errors = errors; break; default: - throw new Error("Unknown mode '" + mode + "', only 'log'/'rethrow' modes are allowed!"); + throw new Error('Unknown mode \'' + mode + '\', only \'log\'/\'rethrow\' modes are allowed!'); } }; @@ -414,8 +414,8 @@ angular.mock.$LogProvider = function() { }); }); if (errors.length) { - errors.unshift("Expected $log to be empty! Either a message was logged unexpectedly, or " + - "an expected log message was not checked and removed:"); + errors.unshift('Expected $log to be empty! Either a message was logged unexpectedly, or ' + + 'an expected log message was not checked and removed:'); errors.push(''); throw new Error(errors.join('\n---------\n')); } @@ -463,7 +463,7 @@ angular.mock.$IntervalProvider = function() { promise = deferred.promise; count = (angular.isDefined(count)) ? count : 0; - promise.then(null, null, (!hasParams) ? fn : function() { + promise.then(null, function() {}, (!hasParams) ? fn : function() { fn.apply(null, args); }); @@ -523,6 +523,7 @@ angular.mock.$IntervalProvider = function() { }); if (angular.isDefined(fnIndex)) { + repeatFns[fnIndex].deferred.promise.then(undefined, function() {}); repeatFns[fnIndex].deferred.reject('canceled'); repeatFns.splice(fnIndex, 1); return true; @@ -558,16 +559,13 @@ angular.mock.$IntervalProvider = function() { }; -/* jshint -W101 */ -/* The R_ISO8061_STR regex is never going to fit into the 100 char limit! - * This directive should go inside the anonymous function but a bug in JSHint means that it would - * not be enacted early enough to prevent the warning. - */ -var R_ISO8061_STR = /^(-?\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?:\:?(\d\d)(?:\:?(\d\d)(?:\.(\d{3}))?)?)?(Z|([+-])(\d\d):?(\d\d)))?$/; - function jsonStringToDate(string) { + // The R_ISO8061_STR regex is never going to fit into the 100 char limit! + // eslit-disable-next-line max-len + var R_ISO8061_STR = /^(-?\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d{3}))?)?)?(Z|([+-])(\d\d):?(\d\d)))?$/; + var match; - if (match = string.match(R_ISO8061_STR)) { + if ((match = string.match(R_ISO8061_STR))) { var date = new Date(0), tzHour = 0, tzMin = 0; @@ -650,9 +648,10 @@ angular.mock.TzDate = function(offset, timestamp) { timestamp = self.origDate.getTime(); if (isNaN(timestamp)) { + // eslint-disable-next-line no-throw-literal throw { - name: "Illegal Argument", - message: "Arg '" + tsStr + "' passed into TzDate constructor is not a valid date string" + name: 'Illegal Argument', + message: 'Arg \'' + tsStr + '\' passed into TzDate constructor is not a valid date string' }; } } else { @@ -758,7 +757,7 @@ angular.mock.TzDate = function(offset, timestamp) { angular.forEach(unimplementedMethods, function(methodName) { self[methodName] = function() { - throw new Error("Method '" + methodName + "' is not implemented in the TzDate mock"); + throw new Error('Method \'' + methodName + '\' is not implemented in the TzDate mock'); }; }); @@ -767,7 +766,6 @@ angular.mock.TzDate = function(offset, timestamp) { //make "tzDateInstance instanceof Date" return true angular.mock.TzDate.prototype = Date.prototype; -/* jshint +W101 */ /** @@ -1215,7 +1213,7 @@ angular.mock.dump = function(object) { $httpBackend.expectPOST('/add-msg.py', undefined, function(headers) { // check if the header was sent, if it wasn't the expectation won't // match the request and the test will fail - return headers['Authorization'] == 'xxx'; + return headers['Authorization'] === 'xxx'; }).respond(201, ''); $rootScope.saveMessage('whatever'); @@ -1357,7 +1355,11 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { function wrapResponse(wrapped) { if (!$browser && timeout) { - timeout.then ? timeout.then(handleTimeout) : $timeout(handleTimeout, timeout); + if (timeout.then) { + timeout.then(handleTimeout); + } else { + $timeout(handleTimeout, timeout); + } } return handleResponse; @@ -1426,7 +1428,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * Creates a new backend definition. * * @param {string} method HTTP method. - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives * data string and returns true if the data is as expected. @@ -1448,6 +1450,9 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * the `requestHandler` object for possible overrides. */ $httpBackend.when = function(method, url, data, headers, keys) { + + assertArgDefined(arguments, 1, 'url'); + var definition = new MockHttpExpectation(method, url, data, headers, keys), chain = { respond: function(status, data, headers, statusText) { @@ -1475,7 +1480,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new backend definition for GET requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(Object|function(Object))=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described above. @@ -1490,7 +1495,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new backend definition for HEAD requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(Object|function(Object))=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described above. @@ -1505,7 +1510,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new backend definition for DELETE requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(Object|function(Object))=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described above. @@ -1520,7 +1525,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new backend definition for POST requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives * data string and returns true if the data is as expected. @@ -1537,7 +1542,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new backend definition for PUT requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives * data string and returns true if the data is as expected. @@ -1554,7 +1559,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new backend definition for JSONP requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described above. * @returns {requestHandler} Returns an object with `respond` method that controls how a matched @@ -1590,7 +1595,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { url = url .replace(/([().])/g, '\\$1') - .replace(/(\/)?:(\w+)([\?\*])?/g, function(_, slash, key, option) { + .replace(/(\/)?:(\w+)([?*])?/g, function(_, slash, key, option) { var optional = option === '?' ? option : null; var star = option === '*' ? option : null; keys.push({ name: key, optional: !!optional }); @@ -1604,7 +1609,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { + ')' + (optional || ''); }) - .replace(/([\/$\*])/g, '\\$1'); + .replace(/([/$*])/g, '\\$1'); ret.regexp = new RegExp('^' + url, 'i'); return ret; @@ -1617,7 +1622,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * Creates a new request expectation. * * @param {string} method HTTP method. - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that * receives data string and returns true if the data is as expected, or Object if request body @@ -1640,6 +1645,9 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * the `requestHandler` object for possible overrides. */ $httpBackend.expect = function(method, url, data, headers, keys) { + + assertArgDefined(arguments, 1, 'url'); + var expectation = new MockHttpExpectation(method, url, data, headers, keys), chain = { respond: function(status, data, headers, statusText) { @@ -1658,7 +1666,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new request expectation for GET requests. For more info see `expect()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {Object=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described above. @@ -1673,7 +1681,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new request expectation for HEAD requests. For more info see `expect()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {Object=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described above. @@ -1688,7 +1696,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new request expectation for DELETE requests. For more info see `expect()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {Object=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described above. @@ -1703,7 +1711,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new request expectation for POST requests. For more info see `expect()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that * receives data string and returns true if the data is as expected, or Object if request body @@ -1721,7 +1729,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new request expectation for PUT requests. For more info see `expect()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that * receives data string and returns true if the data is as expected, or Object if request body @@ -1739,7 +1747,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new request expectation for PATCH requests. For more info see `expect()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that * receives data string and returns true if the data is as expected, or Object if request body @@ -1757,7 +1765,7 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @description * Creates a new request expectation for JSONP requests. For more info see `expect()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives an url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives an url * and returns true if the url matches the current definition. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described above. * @returns {requestHandler} Returns an object with `respond` method that controls how a matched @@ -1788,24 +1796,34 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * @ngdoc method * @name $httpBackend#flush * @description - * Flushes all pending requests using the trained responses. + * Flushes pending requests using the trained responses. Requests are flushed in the order they + * were made, but it is also possible to skip one or more requests (for example to have them + * flushed later). This is useful for simulating scenarios where responses arrive from the server + * in any order. * - * @param {number=} count Number of responses to flush (in the order they arrived). If undefined, - * all pending requests will be flushed. If there are no pending requests when the flush method - * is called an exception is thrown (as this typically a sign of programming error). + * If there are no pending requests to flush when the method is called, an exception is thrown (as + * this is typically a sign of programming error). + * + * @param {number=} count - Number of responses to flush. If undefined/null, all pending requests + * (starting after `skip`) will be flushed. + * @param {number=} [skip=0] - Number of pending requests to skip. For example, a value of `5` + * would skip the first 5 pending requests and start flushing from the 6th onwards. */ - $httpBackend.flush = function(count, digest) { + $httpBackend.flush = function(count, skip, digest) { if (digest !== false) $rootScope.$digest(); - if (!responses.length) throw new Error('No pending request to flush !'); + + skip = skip || 0; + if (skip >= responses.length) throw new Error('No pending request to flush !'); if (angular.isDefined(count) && count !== null) { while (count--) { - if (!responses.length) throw new Error('No more pending request to flush !'); - responses.shift()(); + var part = responses.splice(skip, 1); + if (!part.length) throw new Error('No more pending request to flush !'); + part[0](); } } else { - while (responses.length) { - responses.shift()(); + while (responses.length > skip) { + responses.splice(skip, 1)[0](); } } $httpBackend.verifyNoOutstandingExpectation(digest); @@ -1847,7 +1865,8 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { * afterEach($httpBackend.verifyNoOutstandingRequest); * ``` */ - $httpBackend.verifyNoOutstandingRequest = function() { + $httpBackend.verifyNoOutstandingRequest = function(digest) { + if (digest !== false) $rootScope.$digest(); if (responses.length) { throw new Error('Unflushed requests: ' + responses.length); } @@ -1873,18 +1892,35 @@ function createHttpBackendMock($rootScope, $timeout, $delegate, $browser) { function createShortMethods(prefix) { angular.forEach(['GET', 'DELETE', 'JSONP', 'HEAD'], function(method) { $httpBackend[prefix + method] = function(url, headers, keys) { + assertArgDefined(arguments, 0, 'url'); + + // Change url to `null` if `undefined` to stop it throwing an exception further down + if (angular.isUndefined(url)) url = null; + return $httpBackend[prefix](method, url, undefined, headers, keys); }; }); angular.forEach(['PUT', 'POST', 'PATCH'], function(method) { $httpBackend[prefix + method] = function(url, data, headers, keys) { + assertArgDefined(arguments, 0, 'url'); + + // Change url to `null` if `undefined` to stop it throwing an exception further down + if (angular.isUndefined(url)) url = null; + return $httpBackend[prefix](method, url, data, headers, keys); }; }); } } +function assertArgDefined(args, index, name) { + if (args.length > index && angular.isUndefined(args[index])) { + throw new Error('Undefined argument `' + name + '`; the argument is provided but not defined'); + } +} + + function MockHttpExpectation(method, url, data, headers, keys) { function getUrlParams(u) { @@ -1893,14 +1929,15 @@ function MockHttpExpectation(method, url, data, headers, keys) { } function compareUrl(u) { - return (url.slice(0, url.indexOf('?')) == u.slice(0, u.indexOf('?')) && getUrlParams(url).join() == getUrlParams(u).join()); + return (url.slice(0, url.indexOf('?')) === u.slice(0, u.indexOf('?')) && + getUrlParams(url).join() === getUrlParams(u).join()); } this.data = data; this.headers = headers; this.match = function(m, u, d, h) { - if (method != m) return false; + if (method !== m) return false; if (!this.matchUrl(u)) return false; if (angular.isDefined(d) && !this.matchData(d)) return false; if (angular.isDefined(h) && !this.matchHeaders(h)) return false; @@ -1911,7 +1948,7 @@ function MockHttpExpectation(method, url, data, headers, keys) { if (!url) return true; if (angular.isFunction(url.test)) return url.test(u); if (angular.isFunction(url)) return url(u); - return (url == u || compareUrl(u)); + return (url === u || compareUrl(u)); }; this.matchHeaders = function(h) { @@ -1927,6 +1964,7 @@ function MockHttpExpectation(method, url, data, headers, keys) { if (data && !angular.isString(data)) { return angular.equals(angular.fromJson(angular.toJson(data)), angular.fromJson(d)); } + // eslint-disable-next-line eqeqeq return data == d; }; @@ -1958,7 +1996,7 @@ function MockHttpExpectation(method, url, data, headers, keys) { var obj = {}, key_value, key, queryStr = u.indexOf('?') > -1 ? u.substring(u.indexOf('?') + 1) - : ""; + : ''; angular.forEach(queryStr.split('&'), function(keyValue) { if (keyValue) { @@ -2025,7 +2063,7 @@ function MockXhr() { header = undefined; angular.forEach(this.$$respHeaders, function(headerVal, headerName) { - if (!header && angular.lowercase(headerName) == name) header = headerVal; + if (!header && angular.lowercase(headerName) === name) header = headerVal; }); return header; }; @@ -2098,7 +2136,7 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ function formatPendingTasksAsString(tasks) { var result = []; angular.forEach(tasks, function(task) { - result.push('{id: ' + task.id + ', ' + 'time: ' + task.time + '}'); + result.push('{id: ' + task.id + ', time: ' + task.time + '}'); }); return result.join(', '); @@ -2153,6 +2191,10 @@ angular.mock.$RootElementProvider = function() { * A decorator for {@link ng.$controller} with additional `bindings` parameter, useful when testing * controllers of directives that use {@link $compile#-bindtocontroller- `bindToController`}. * + * Depending on the value of + * {@link ng.$compileProvider#preAssignBindingsEnabled `preAssignBindingsEnabled()`}, the properties + * will be bound before or after invoking the constructor. + * * * ## Example * @@ -2171,18 +2213,24 @@ angular.mock.$RootElementProvider = function() { * // Controller definition ... * * myMod.controller('MyDirectiveController', ['$log', function($log) { - * $log.info(this.name); + * this.log = function() { + * $log.info(this.name); + * }; * }]); * * * // In a test ... * * describe('myDirectiveController', function() { - * it('should write the bound name to the log', inject(function($controller, $log) { - * var ctrl = $controller('MyDirectiveController', { /* no locals */ }, { name: 'Clark Kent' }); - * expect(ctrl.name).toEqual('Clark Kent'); - * expect($log.info.logs).toEqual(['Clark Kent']); - * })); + * describe('log()', function() { + * it('should write the bound name to the log', inject(function($controller, $log) { + * var ctrl = $controller('MyDirectiveController', { /* no locals */ }, { name: 'Clark Kent' }); + * ctrl.log(); + * + * expect(ctrl.name).toEqual('Clark Kent'); + * expect($log.info.logs).toEqual(['Clark Kent']); + * })); + * }); * }); * * ``` @@ -2194,44 +2242,61 @@ angular.mock.$RootElementProvider = function() { * * check if a controller with given name is registered via `$controllerProvider` * * check if evaluating the string on the current scope returns a constructor * * if $controllerProvider#allowGlobals, check `window[constructor]` on the global - * `window` object (not recommended) + * `window` object (deprecated, not recommended) * * The string can use the `controller as property` syntax, where the controller instance is published * as the specified property on the `scope`; the `scope` must be injected into `locals` param for this * to work correctly. * * @param {Object} locals Injection locals for Controller. - * @param {Object=} bindings Properties to add to the controller before invoking the constructor. This is used - * to simulate the `bindToController` feature and simplify certain kinds of tests. + * @param {Object=} bindings Properties to add to the controller instance. This is used to simulate + * the `bindToController` feature and simplify certain kinds of tests. * @return {Object} Instance of given controller. */ -angular.mock.$ControllerDecorator = ['$delegate', function($delegate) { - return function(expression, locals, later, ident) { - if (later && typeof later === 'object') { - var instantiate = $delegate(expression, locals, true, ident); - angular.extend(instantiate.instance, later); +function createControllerDecorator(compileProvider) { + angular.mock.$ControllerDecorator = ['$delegate', function($delegate) { + return function(expression, locals, later, ident) { + if (later && typeof later === 'object') { + var preAssignBindingsEnabled = compileProvider.preAssignBindingsEnabled(); - var instance = instantiate(); - if (instance !== instantiate.instance) { - angular.extend(instance, later); + var instantiate = $delegate(expression, locals, true, ident); + if (preAssignBindingsEnabled) { + angular.extend(instantiate.instance, later); + } + + var instance = instantiate(); + if (!preAssignBindingsEnabled || instance !== instantiate.instance) { + angular.extend(instance, later); + } + + return instance; } + return $delegate(expression, locals, later, ident); + }; + }]; - return instance; - } - return $delegate(expression, locals, later, ident); - }; -}]; + return angular.mock.$ControllerDecorator; +} /** * @ngdoc service * @name $componentController * @description - * A service that can be used to create instances of component controllers. - *
    + * A service that can be used to create instances of component controllers. Useful for unit-testing. + * * Be aware that the controller will be instantiated and attached to the scope as specified in * the component definition object. If you do not provide a `$scope` object in the `locals` param * then the helper will create a new isolated scope as a child of `$rootScope`. - *
    + * + * If you are using `$element` or `$attrs` in the controller, make sure to provide them as `locals`. + * The `$element` must be a jqLite-wrapped DOM element, and `$attrs` should be an object that + * has all properties / functions that you are using in the controller. If this is getting too complex, + * you should compile the component instead and access the component's controller via the + * {@link angular.element#methods `controller`} function. + * + * See also the section on {@link guide/component#unit-testing-component-controllers unit-testing component controllers} + * in the guide. + * * @param {string} componentName the name of the component whose controller we want to instantiate * @param {Object} locals Injection locals for Controller. * @param {Object=} bindings Properties to add to the controller before invoking the constructor. This is used @@ -2239,7 +2304,8 @@ angular.mock.$ControllerDecorator = ['$delegate', function($delegate) { * @param {string=} ident Override the property name to use when attaching the controller to the scope. * @return {Object} Instance of requested controller. */ -angular.mock.$ComponentControllerProvider = ['$compileProvider', function($compileProvider) { +angular.mock.$ComponentControllerProvider = ['$compileProvider', + function ComponentControllerProvider($compileProvider) { this.$get = ['$controller','$injector', '$rootScope', function($controller, $injector, $rootScope) { return function $componentController(componentName, locals, bindings, ident) { // get all directives associated to the component name @@ -2288,6 +2354,7 @@ angular.mock.$ComponentControllerProvider = ['$compileProvider', function($compi * * [Google CDN](https://developers.google.com/speed/libraries/devguide#angularjs) e.g. * `"//ajax.googleapis.com/ajax/libs/angularjs/X.Y.Z/angular-mocks.js"` * * [NPM](https://www.npmjs.com/) e.g. `npm install angular-mocks@X.Y.Z` + * * [Yarn](https://yarnpkg.com) e.g. `yarn add angular-mocks@X.Y.Z` * * [Bower](http://bower.io) e.g. `bower install angular-mocks#X.Y.Z` * * [code.angularjs.org](https://code.angularjs.org/) (discouraged for production use) e.g. * `"//code.angularjs.org/X.Y.Z/angular-mocks.js"` @@ -2319,11 +2386,11 @@ angular.module('ngMock', ['ng']).provider({ $httpBackend: angular.mock.$HttpBackendProvider, $rootElement: angular.mock.$RootElementProvider, $componentController: angular.mock.$ComponentControllerProvider -}).config(['$provide', function($provide) { +}).config(['$provide', '$compileProvider', function($provide, $compileProvider) { $provide.decorator('$timeout', angular.mock.$TimeoutDecorator); $provide.decorator('$$rAF', angular.mock.$RAFDecorator); $provide.decorator('$rootScope', angular.mock.$RootScopeDecorator); - $provide.decorator('$controller', angular.mock.$ControllerDecorator); + $provide.decorator('$controller', createControllerDecorator($compileProvider)); }]); /** @@ -2387,7 +2454,7 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * phones.push(phone); * return [200, phone, {}]; * }); - * $httpBackend.whenGET(/^\/templates\//).passThrough(); // Requests for templare are handled by the real server + * $httpBackend.whenGET(/^\/templates\//).passThrough(); // Requests for templates are handled by the real server * //... * }); * ``` @@ -2399,7 +2466,7 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * * var myApp = angular.module('myApp', []); * - * myApp.controller('main', function($http) { + * myApp.controller('MainCtrl', function MainCtrl($http) { * var ctrl = this; * * ctrl.phones = []; @@ -2441,7 +2508,7 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * }); * * - *
    + *
    * * * @@ -2465,9 +2532,10 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * Creates a new backend definition. * * @param {string} method HTTP method. - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. - * @param {(string|RegExp)=} data HTTP request body. + * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives + * data string and returns true if the data is as expected. * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header * object and returns true if the headers match the current definition. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described on @@ -2497,7 +2565,7 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * @description * Creates a new backend definition for GET requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(Object|function(Object))=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described on @@ -2514,7 +2582,7 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * @description * Creates a new backend definition for HEAD requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(Object|function(Object))=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described on @@ -2531,7 +2599,7 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * @description * Creates a new backend definition for DELETE requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(Object|function(Object))=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described on @@ -2548,9 +2616,10 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * @description * Creates a new backend definition for POST requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. - * @param {(string|RegExp)=} data HTTP request body. + * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives + * data string and returns true if the data is as expected. * @param {(Object|function(Object))=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described on * {@link ngMock.$httpBackend $httpBackend mock}. @@ -2566,9 +2635,10 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * @description * Creates a new backend definition for PUT requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. - * @param {(string|RegExp)=} data HTTP request body. + * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives + * data string and returns true if the data is as expected. * @param {(Object|function(Object))=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described on * {@link ngMock.$httpBackend $httpBackend mock}. @@ -2584,9 +2654,10 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * @description * Creates a new backend definition for PATCH requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. - * @param {(string|RegExp)=} data HTTP request body. + * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives + * data string and returns true if the data is as expected. * @param {(Object|function(Object))=} headers HTTP headers. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described on * {@link ngMock.$httpBackend $httpBackend mock}. @@ -2602,7 +2673,7 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) { * @description * Creates a new backend definition for JSONP requests. For more info see `when()`. * - * @param {string|RegExp|function(string)} url HTTP url or function that receives a url + * @param {string|RegExp|function(string)=} url HTTP url or function that receives a url * and returns true if the url matches the current definition. * @param {(Array)=} keys Array of keys to assign to regex matches in request url described on * {@link ngMock.$httpBackend $httpBackend mock}. @@ -2654,6 +2725,7 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { * @ngdoc method * @name $rootScope.Scope#$countChildScopes * @module ngMock + * @this $rootScope.Scope * @description * Counts all the direct and indirect child scopes of the current scope. * @@ -2662,7 +2734,6 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { * @returns {number} Total number of child scopes. */ function countChildScopes() { - // jshint validthis: true var count = 0; // exclude the current scope var pendingChildHeads = [this.$$childHead]; var currentScope; @@ -2684,6 +2755,7 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { /** * @ngdoc method * @name $rootScope.Scope#$countWatchers + * @this $rootScope.Scope * @module ngMock * @description * Counts all the watchers of direct and indirect child scopes of the current scope. @@ -2694,7 +2766,6 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { * @returns {number} Total number of watchers. */ function countWatchers() { - // jshint validthis: true var count = this.$$watchers ? this.$$watchers.length : 0; // include the current scope var pendingChildHeads = [this.$$childHead]; var currentScope; @@ -2714,7 +2785,7 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { }]; -!(function(jasmineOrMocha) { +(function(jasmineOrMocha) { if (!jasmineOrMocha) { return; @@ -2809,7 +2880,7 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { * * You cannot call `sharedInjector()` from within a context already using `sharedInjector()`. * - * ## Example + * ## Example * * Typically beforeAll is used to make many assertions about a single operation. This can * cut down test run-time as the test setup doesn't need to be re-run, and enabling focussed @@ -2847,14 +2918,14 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { */ module.sharedInjector = function() { if (!(module.$$beforeAllHook && module.$$afterAllHook)) { - throw Error("sharedInjector() cannot be used unless your test runner defines beforeAll/afterAll"); + throw Error('sharedInjector() cannot be used unless your test runner defines beforeAll/afterAll'); } var initialized = false; - module.$$beforeAllHook(function() { + module.$$beforeAllHook(/** @this */ function() { if (injectorState.shared) { - injectorState.sharedError = Error("sharedInjector() cannot be called inside a context that has already called sharedInjector()"); + injectorState.sharedError = Error('sharedInjector() cannot be called inside a context that has already called sharedInjector()'); throw injectorState.sharedError; } initialized = true; @@ -2873,10 +2944,10 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { }; module.$$beforeEach = function() { - if (injectorState.shared && currentSpec && currentSpec != this) { + if (injectorState.shared && currentSpec && currentSpec !== this) { var state = currentSpec; currentSpec = this; - angular.forEach(["$injector","$modules","$providerInjector", "$injectorStrict"], function(k) { + angular.forEach(['$injector','$modules','$providerInjector', '$injectorStrict'], function(k) { currentSpec[k] = state[k]; state[k] = null; }); @@ -2967,7 +3038,7 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { * These are ignored by the injector when the reference name is resolved. * * For example, the parameter `_myService_` would be resolved as the reference `myService`. - * Since it is available in the function body as _myService_, we can then assign it to a variable + * Since it is available in the function body as `_myService_`, we can then assign it to a variable * defined in an outer scope. * * ``` @@ -3031,7 +3102,7 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { - var ErrorAddingDeclarationLocationStack = function(e, errorForStack) { + var ErrorAddingDeclarationLocationStack = function ErrorAddingDeclarationLocationStack(e, errorForStack) { this.message = e.message; this.name = e.name; if (e.line) this.line = e.line; @@ -3049,11 +3120,11 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { if (!errorForStack.stack) { try { throw errorForStack; - } catch (e) {} + } catch (e) { /* empty */ } } - return wasInjectorCreated() ? workFn.call(currentSpec) : workFn; + return wasInjectorCreated() ? WorkFn.call(currentSpec) : WorkFn; ///////////////////// - function workFn() { + function WorkFn() { var modules = currentSpec.$modules || []; var strictDi = !!currentSpec.$injectorStrict; modules.unshift(['$injector', function($injector) { @@ -3066,7 +3137,7 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { if (strictDi) { // If strictDi is enabled, annotate the providerInjector blocks angular.forEach(modules, function(moduleFn) { - if (typeof moduleFn === "function") { + if (typeof moduleFn === 'function') { angular.injector.$$annotate(moduleFn); } }); @@ -3081,9 +3152,7 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { injector.annotate(blockFns[i]); } try { - /* jshint -W040 *//* Jasmine explicitly provides a `this` object when calling functions */ injector.invoke(blockFns[i] || angular.noop, this); - /* jshint +W040 */ } catch (e) { if (e.stack && errorForStack) { throw new ErrorAddingDeclarationLocationStack(e, errorForStack); @@ -3122,5 +3191,218 @@ angular.mock.$RootScopeDecorator = ['$delegate', function($delegate) { } })(window.jasmine || window.mocha); +'use strict'; + +(function() { + /** + * Triggers a browser event. Attempts to choose the right event if one is + * not specified. + * + * @param {Object} element Either a wrapped jQuery/jqLite node or a DOMElement + * @param {string} eventType Optional event type + * @param {Object=} eventData An optional object which contains additional event data (such as x,y + * coordinates, keys, etc...) that are passed into the event when triggered + */ + window.browserTrigger = function browserTrigger(element, eventType, eventData) { + if (element && !element.nodeName) element = element[0]; + if (!element) return; + + eventData = eventData || {}; + var relatedTarget = eventData.relatedTarget || element; + var keys = eventData.keys; + var x = eventData.x; + var y = eventData.y; + + var inputType = (element.type) ? element.type.toLowerCase() : null, + nodeName = element.nodeName.toLowerCase(); + if (!eventType) { + eventType = { + 'text': 'change', + 'textarea': 'change', + 'hidden': 'change', + 'password': 'change', + 'button': 'click', + 'submit': 'click', + 'reset': 'click', + 'image': 'click', + 'checkbox': 'click', + 'radio': 'click', + 'select-one': 'change', + 'select-multiple': 'change', + '_default_': 'click' + }[inputType || '_default_']; + } + + if (nodeName === 'option') { + element.parentNode.value = element.value; + element = element.parentNode; + eventType = 'change'; + } + + keys = keys || []; + function pressed(key) { + return keys.indexOf(key) !== -1; + } + + var evnt; + if (/transitionend/.test(eventType)) { + if (window.WebKitTransitionEvent) { + evnt = new window.WebKitTransitionEvent(eventType, eventData); + evnt.initEvent(eventType, false, true); + } else { + try { + evnt = new window.TransitionEvent(eventType, eventData); + } catch (e) { + evnt = window.document.createEvent('TransitionEvent'); + evnt.initTransitionEvent(eventType, null, null, null, eventData.elapsedTime || 0); + } + } + } else if (/animationend/.test(eventType)) { + if (window.WebKitAnimationEvent) { + evnt = new window.WebKitAnimationEvent(eventType, eventData); + evnt.initEvent(eventType, false, true); + } else { + try { + evnt = new window.AnimationEvent(eventType, eventData); + } catch (e) { + evnt = window.document.createEvent('AnimationEvent'); + evnt.initAnimationEvent(eventType, null, null, null, eventData.elapsedTime || 0); + } + } + } else if (/touch/.test(eventType) && supportsTouchEvents()) { + evnt = createTouchEvent(element, eventType, x, y); + } else if (/key/.test(eventType)) { + evnt = window.document.createEvent('Events'); + evnt.initEvent(eventType, eventData.bubbles, eventData.cancelable); + evnt.view = window; + evnt.ctrlKey = pressed('ctrl'); + evnt.altKey = pressed('alt'); + evnt.shiftKey = pressed('shift'); + evnt.metaKey = pressed('meta'); + evnt.keyCode = eventData.keyCode; + evnt.charCode = eventData.charCode; + evnt.which = eventData.which; + } else { + evnt = window.document.createEvent('MouseEvents'); + x = x || 0; + y = y || 0; + evnt.initMouseEvent(eventType, true, true, window, 0, x, y, x, y, pressed('ctrl'), + pressed('alt'), pressed('shift'), pressed('meta'), 0, relatedTarget); + } + + /* we're unable to change the timeStamp value directly so this + * is only here to allow for testing where the timeStamp value is + * read */ + evnt.$manualTimeStamp = eventData.timeStamp; + + if (!evnt) return; + + var originalPreventDefault = evnt.preventDefault, + appWindow = element.ownerDocument.defaultView, + fakeProcessDefault = true, + finalProcessDefault, + angular = appWindow.angular || {}; + + // igor: temporary fix for https://bugzilla.mozilla.org/show_bug.cgi?id=684208 + angular['ff-684208-preventDefault'] = false; + evnt.preventDefault = function() { + fakeProcessDefault = false; + return originalPreventDefault.apply(evnt, arguments); + }; + + if (!eventData.bubbles || supportsEventBubblingInDetachedTree() || isAttachedToDocument(element)) { + element.dispatchEvent(evnt); + } else { + triggerForPath(element, evnt); + } + + finalProcessDefault = !(angular['ff-684208-preventDefault'] || !fakeProcessDefault); + + delete angular['ff-684208-preventDefault']; + + return finalProcessDefault; + }; + + function supportsTouchEvents() { + if ('_cached' in supportsTouchEvents) { + return supportsTouchEvents._cached; + } + if (!window.document.createTouch || !window.document.createTouchList) { + supportsTouchEvents._cached = false; + return false; + } + try { + window.document.createEvent('TouchEvent'); + } catch (e) { + supportsTouchEvents._cached = false; + return false; + } + supportsTouchEvents._cached = true; + return true; + } + + function createTouchEvent(element, eventType, x, y) { + var evnt = new window.Event(eventType); + x = x || 0; + y = y || 0; + + var touch = window.document.createTouch(window, element, Date.now(), x, y, x, y); + var touches = window.document.createTouchList(touch); + + evnt.touches = touches; + + return evnt; + } + + function supportsEventBubblingInDetachedTree() { + if ('_cached' in supportsEventBubblingInDetachedTree) { + return supportsEventBubblingInDetachedTree._cached; + } + supportsEventBubblingInDetachedTree._cached = false; + var doc = window.document; + if (doc) { + var parent = doc.createElement('div'), + child = parent.cloneNode(); + parent.appendChild(child); + parent.addEventListener('e', function() { + supportsEventBubblingInDetachedTree._cached = true; + }); + var evnt = window.document.createEvent('Events'); + evnt.initEvent('e', true, true); + child.dispatchEvent(evnt); + } + return supportsEventBubblingInDetachedTree._cached; + } + + function triggerForPath(element, evnt) { + var stop = false; + + var _stopPropagation = evnt.stopPropagation; + evnt.stopPropagation = function() { + stop = true; + _stopPropagation.apply(evnt, arguments); + }; + patchEventTargetForBubbling(evnt, element); + do { + element.dispatchEvent(evnt); + // eslint-disable-next-line no-unmodified-loop-condition + } while (!stop && (element = element.parentNode)); + } + + function patchEventTargetForBubbling(event, target) { + event._target = target; + Object.defineProperty(event, 'target', {get: function() { return this._target;}}); + } + + function isAttachedToDocument(element) { + while ((element = element.parentNode)) { + if (element === window) { + return true; + } + } + return false; + } +})(); + })(window, window.angular); diff --git a/public/vendor/angular-mocks/bower.json b/public/vendor/angular-mocks/bower.json index 3a3d60a6b06..5cd129ac3eb 100644 --- a/public/vendor/angular-mocks/bower.json +++ b/public/vendor/angular-mocks/bower.json @@ -1,10 +1,10 @@ { "name": "angular-mocks", - "version": "1.5.8", + "version": "1.6.1", "license": "MIT", "main": "./angular-mocks.js", "ignore": [], "dependencies": { - "angular": "1.5.8" + "angular": "1.6.1" } } diff --git a/public/vendor/angular-mocks/package.json b/public/vendor/angular-mocks/package.json index 631e187985b..6661aafe4ac 100644 --- a/public/vendor/angular-mocks/package.json +++ b/public/vendor/angular-mocks/package.json @@ -1,6 +1,6 @@ { "name": "angular-mocks", - "version": "1.5.8", + "version": "1.6.1", "description": "AngularJS mocks for testing", "main": "angular-mocks.js", "scripts": { diff --git a/public/vendor/angular-route/.bower.json b/public/vendor/angular-route/.bower.json index 9639a728a0e..5b5ce26b9d3 100644 --- a/public/vendor/angular-route/.bower.json +++ b/public/vendor/angular-route/.bower.json @@ -1,20 +1,20 @@ { "name": "angular-route", - "version": "1.5.8", + "version": "1.6.1", "license": "MIT", "main": "./angular-route.js", "ignore": [], "dependencies": { - "angular": "1.5.8" + "angular": "1.6.1" }, "homepage": "https://github.com/angular/bower-angular-route", - "_release": "1.5.8", + "_release": "1.6.1", "_resolution": { "type": "version", - "tag": "v1.5.8", - "commit": "e96eff424fdd9689061659603ca59470375bf024" + "tag": "v1.6.1", + "commit": "409c45cfc589d66457f7cbb11aa1fc47f8dbbf78" }, "_source": "https://github.com/angular/bower-angular-route.git", - "_target": "1.5.8", + "_target": "1.6.1", "_originalSource": "angular-route" } \ No newline at end of file diff --git a/public/vendor/angular-route/angular-route.js b/public/vendor/angular-route/angular-route.js index 6654d83afef..42e25cec361 100644 --- a/public/vendor/angular-route/angular-route.js +++ b/public/vendor/angular-route/angular-route.js @@ -1,5 +1,5 @@ /** - * @license AngularJS v1.5.8 + * @license AngularJS v1.6.1 * (c) 2010-2016 Google, Inc. http://angularjs.org * License: MIT */ @@ -34,10 +34,11 @@ function shallowCopy(src, dst) { /* global shallowCopy: false */ -// There are necessary for `shallowCopy()` (included via `src/shallowCopy.js`). +// `isArray` and `isObject` are necessary for `shallowCopy()` (included via `src/shallowCopy.js`). // They are initialized inside the `$RouteProvider`, to ensure `window.angular` is available. var isArray; var isObject; +var isDefined; /** * @ngdoc module @@ -54,14 +55,22 @@ var isObject; * *
    */ - /* global -ngRouteModule */ -var ngRouteModule = angular.module('ngRoute', ['ng']). - provider('$route', $RouteProvider), - $routeMinErr = angular.$$minErr('ngRoute'); +/* global -ngRouteModule */ +var ngRouteModule = angular. + module('ngRoute', []). + provider('$route', $RouteProvider). + // Ensure `$route` will be instantiated in time to capture the initial `$locationChangeSuccess` + // event (unless explicitly disabled). This is necessary in case `ngView` is included in an + // asynchronously loaded template. + run(instantiateRoute); +var $routeMinErr = angular.$$minErr('ngRoute'); +var isEagerInstantiationEnabled; + /** * @ngdoc provider * @name $routeProvider + * @this * * @description * @@ -76,6 +85,7 @@ var ngRouteModule = angular.module('ngRoute', ['ng']). function $RouteProvider() { isArray = angular.isArray; isObject = angular.isObject; + isDefined = angular.isDefined; function inherit(parent, extra) { return angular.extend(Object.create(parent), extra); @@ -112,12 +122,12 @@ function $RouteProvider() { * * Object properties: * - * - `controller` – `{(string|function()=}` – Controller fn that should be associated with + * - `controller` – `{(string|Function)=}` – Controller fn that should be associated with * newly created scope or the name of a {@link angular.Module#controller registered * controller} if passed as a string. * - `controllerAs` – `{string=}` – An identifier name for a reference to the controller. * If present, the controller will be published to scope under the `controllerAs` name. - * - `template` – `{string=|function()=}` – html template as a string or a function that + * - `template` – `{(string|Function)=}` – html template as a string or a function that * returns an html template as a string which should be used by {@link * ngRoute.directive:ngView ngView} or {@link ng.directive:ngInclude ngInclude} directives. * This property takes precedence over `templateUrl`. @@ -127,7 +137,9 @@ function $RouteProvider() { * - `{Array.}` - route parameters extracted from the current * `$location.path()` by applying the current route * - * - `templateUrl` – `{string=|function()=}` – path or function that returns a path to an html + * One of `template` or `templateUrl` is required. + * + * - `templateUrl` – `{(string|Function)=}` – path or function that returns a path to an html * template that should be used by {@link ngRoute.directive:ngView ngView}. * * If `templateUrl` is a function, it will be called with the following parameters: @@ -135,7 +147,9 @@ function $RouteProvider() { * - `{Array.}` - route parameters extracted from the current * `$location.path()` by applying the current route * - * - `resolve` - `{Object.=}` - An optional map of dependencies which should + * One of `templateUrl` or `template` is required. + * + * - `resolve` - `{Object.=}` - An optional map of dependencies which should * be injected into the controller. If any of these dependencies are promises, the router * will wait for them all to be resolved or one to be rejected before the controller is * instantiated. @@ -155,7 +169,7 @@ function $RouteProvider() { * The map object is: * * - `key` – `{string}`: a name of a dependency to be injected into the controller. - * - `factory` - `{string|function}`: If `string` then it is an alias for a service. + * - `factory` - `{string|Function}`: If `string` then it is an alias for a service. * Otherwise if function, then it is {@link auto.$injector#invoke injected} * and the return value is treated as the dependency. If the result is a promise, it is * resolved before its value is injected into the controller. Be aware that @@ -165,7 +179,7 @@ function $RouteProvider() { * - `resolveAs` - `{string=}` - The name under which the `resolve` map will be available on * the scope of the route. If omitted, defaults to `$resolve`. * - * - `redirectTo` – `{(string|function())=}` – value to update + * - `redirectTo` – `{(string|Function)=}` – value to update * {@link ng.$location $location} path with and trigger route redirection. * * If `redirectTo` is a function, it will be called with the following parameters: @@ -176,7 +190,31 @@ function $RouteProvider() { * - `{Object}` - current `$location.search()` * * The custom `redirectTo` function is expected to return a string which will be used - * to update `$location.path()` and `$location.search()`. + * to update `$location.url()`. If the function throws an error, no further processing will + * take place and the {@link ngRoute.$route#$routeChangeError $routeChangeError} event will + * be fired. + * + * Routes that specify `redirectTo` will not have their controllers, template functions + * or resolves called, the `$location` will be changed to the redirect url and route + * processing will stop. The exception to this is if the `redirectTo` is a function that + * returns `undefined`. In this case the route transition occurs as though there was no + * redirection. + * + * - `resolveRedirectTo` – `{Function=}` – a function that will (eventually) return the value + * to update {@link ng.$location $location} URL with and trigger route redirection. In + * contrast to `redirectTo`, dependencies can be injected into `resolveRedirectTo` and the + * return value can be either a string or a promise that will be resolved to a string. + * + * Similar to `redirectTo`, if the return value is `undefined` (or a promise that gets + * resolved to `undefined`), no redirection takes place and the route transition occurs as + * though there was no redirection. + * + * If the function throws an error or the returned promise gets rejected, no further + * processing will take place and the + * {@link ngRoute.$route#$routeChangeError $routeChangeError} event will be fired. + * + * `redirectTo` takes precedence over `resolveRedirectTo`, so specifying both on the same + * route definition, will cause the latter to be ignored. * * - `[reloadOnSearch=true]` - `{boolean=}` - reload route when only `$location.search()` * or `$location.hash()` changes. @@ -210,7 +248,7 @@ function $RouteProvider() { // create redirection for trailing slashes if (path) { - var redirectPath = (path[path.length - 1] == '/') + var redirectPath = (path[path.length - 1] === '/') ? path.substr(0, path.length - 1) : path + '/'; @@ -255,7 +293,7 @@ function $RouteProvider() { path = path .replace(/([().])/g, '\\$1') - .replace(/(\/)?:(\w+)(\*\?|[\?\*])?/g, function(_, slash, key, option) { + .replace(/(\/)?:(\w+)(\*\?|[?*])?/g, function(_, slash, key, option) { var optional = (option === '?' || option === '*?') ? '?' : null; var star = (option === '*' || option === '*?') ? '*' : null; keys.push({ name: key, optional: !!optional }); @@ -269,7 +307,7 @@ function $RouteProvider() { + ')' + (optional || ''); }) - .replace(/([\/$\*])/g, '\\$1'); + .replace(/([/$*])/g, '\\$1'); ret.regexp = new RegExp('^' + path + '$', insensitive ? 'i' : ''); return ret; @@ -295,6 +333,47 @@ function $RouteProvider() { return this; }; + /** + * @ngdoc method + * @name $routeProvider#eagerInstantiationEnabled + * @kind function + * + * @description + * Call this method as a setter to enable/disable eager instantiation of the + * {@link ngRoute.$route $route} service upon application bootstrap. You can also call it as a + * getter (i.e. without any arguments) to get the current value of the + * `eagerInstantiationEnabled` flag. + * + * Instantiating `$route` early is necessary for capturing the initial + * {@link ng.$location#$locationChangeStart $locationChangeStart} event and navigating to the + * appropriate route. Usually, `$route` is instantiated in time by the + * {@link ngRoute.ngView ngView} directive. Yet, in cases where `ngView` is included in an + * asynchronously loaded template (e.g. in another directive's template), the directive factory + * might not be called soon enough for `$route` to be instantiated _before_ the initial + * `$locationChangeSuccess` event is fired. Eager instantiation ensures that `$route` is always + * instantiated in time, regardless of when `ngView` will be loaded. + * + * The default value is true. + * + * **Note**:
    + * You may want to disable the default behavior when unit-testing modules that depend on + * `ngRoute`, in order to avoid an unexpected request for the default route's template. + * + * @param {boolean=} enabled - If provided, update the internal `eagerInstantiationEnabled` flag. + * + * @returns {*} The current value of the `eagerInstantiationEnabled` flag if used as a getter or + * itself (for chaining) if used as a setter. + */ + isEagerInstantiationEnabled = true; + this.eagerInstantiationEnabled = function eagerInstantiationEnabled(enabled) { + if (isDefined(enabled)) { + isEagerInstantiationEnabled = enabled; + return this; + } + + return isEagerInstantiationEnabled; + }; + this.$get = ['$rootScope', '$location', @@ -388,12 +467,12 @@ function $RouteProvider() { * }) * * .controller('BookController', function($scope, $routeParams) { - * $scope.name = "BookController"; + * $scope.name = 'BookController'; * $scope.params = $routeParams; * }) * * .controller('ChapterController', function($scope, $routeParams) { - * $scope.name = "ChapterController"; + * $scope.name = 'ChapterController'; * $scope.params = $routeParams; * }) * @@ -426,15 +505,15 @@ function $RouteProvider() { * it('should load and compile correct template', function() { * element(by.linkText('Moby: Ch1')).click(); * var content = element(by.css('[ng-view]')).getText(); - * expect(content).toMatch(/controller\: ChapterController/); - * expect(content).toMatch(/Book Id\: Moby/); - * expect(content).toMatch(/Chapter Id\: 1/); + * expect(content).toMatch(/controller: ChapterController/); + * expect(content).toMatch(/Book Id: Moby/); + * expect(content).toMatch(/Chapter Id: 1/); * * element(by.partialLinkText('Scarlet')).click(); * * content = element(by.css('[ng-view]')).getText(); - * expect(content).toMatch(/controller\: BookController/); - * expect(content).toMatch(/Book Id\: Scarlet/); + * expect(content).toMatch(/controller: BookController/); + * expect(content).toMatch(/Book Id: Scarlet/); * }); * * @@ -482,12 +561,14 @@ function $RouteProvider() { * @name $route#$routeChangeError * @eventType broadcast on root scope * @description - * Broadcasted if any of the resolve promises are rejected. + * Broadcasted if a redirection function fails or any redirection or resolve promises are + * rejected. * * @param {Object} angularEvent Synthetic event object * @param {Route} current Current route information. * @param {Route} previous Previous route information. - * @param {Route} rejection Rejection of the promise. Usually the error of the failed promise. + * @param {Route} rejection The thrown error or the rejection reason of the promise. Usually + * the rejection reason is the error that caused the promise to get rejected. */ /** @@ -628,37 +709,103 @@ function $RouteProvider() { } else if (nextRoute || lastRoute) { forceReload = false; $route.current = nextRoute; - if (nextRoute) { - if (nextRoute.redirectTo) { - if (angular.isString(nextRoute.redirectTo)) { - $location.path(interpolate(nextRoute.redirectTo, nextRoute.params)).search(nextRoute.params) - .replace(); - } else { - $location.url(nextRoute.redirectTo(nextRoute.pathParams, $location.path(), $location.search())) - .replace(); - } - } - } - $q.when(nextRoute). - then(resolveLocals). - then(function(locals) { - // after route change - if (nextRoute == $route.current) { - if (nextRoute) { - nextRoute.locals = locals; - angular.copy(nextRoute.params, $routeParams); - } - $rootScope.$broadcast('$routeChangeSuccess', nextRoute, lastRoute); - } - }, function(error) { - if (nextRoute == $route.current) { + var nextRoutePromise = $q.resolve(nextRoute); + + nextRoutePromise. + then(getRedirectionData). + then(handlePossibleRedirection). + then(function(keepProcessingRoute) { + return keepProcessingRoute && nextRoutePromise. + then(resolveLocals). + then(function(locals) { + // after route change + if (nextRoute === $route.current) { + if (nextRoute) { + nextRoute.locals = locals; + angular.copy(nextRoute.params, $routeParams); + } + $rootScope.$broadcast('$routeChangeSuccess', nextRoute, lastRoute); + } + }); + }).catch(function(error) { + if (nextRoute === $route.current) { $rootScope.$broadcast('$routeChangeError', nextRoute, lastRoute, error); } }); } } + function getRedirectionData(route) { + var data = { + route: route, + hasRedirection: false + }; + + if (route) { + if (route.redirectTo) { + if (angular.isString(route.redirectTo)) { + data.path = interpolate(route.redirectTo, route.params); + data.search = route.params; + data.hasRedirection = true; + } else { + var oldPath = $location.path(); + var oldSearch = $location.search(); + var newUrl = route.redirectTo(route.pathParams, oldPath, oldSearch); + + if (angular.isDefined(newUrl)) { + data.url = newUrl; + data.hasRedirection = true; + } + } + } else if (route.resolveRedirectTo) { + return $q. + resolve($injector.invoke(route.resolveRedirectTo)). + then(function(newUrl) { + if (angular.isDefined(newUrl)) { + data.url = newUrl; + data.hasRedirection = true; + } + + return data; + }); + } + } + + return data; + } + + function handlePossibleRedirection(data) { + var keepProcessingRoute = true; + + if (data.route !== $route.current) { + keepProcessingRoute = false; + } else if (data.hasRedirection) { + var oldUrl = $location.url(); + var newUrl = data.url; + + if (newUrl) { + $location. + url(newUrl). + replace(); + } else { + newUrl = $location. + path(data.path). + search(data.search). + replace(). + url(); + } + + if (newUrl !== oldUrl) { + // Exit out and don't process current next value, + // wait for next location change from redirect + keepProcessingRoute = false; + } + } + + return keepProcessingRoute; + } + function resolveLocals(route) { if (route) { var locals = angular.extend({}, route.resolve); @@ -675,7 +822,6 @@ function $RouteProvider() { } } - function getTemplateFor(route) { var template, templateUrl; if (angular.isDefined(template = route.template)) { @@ -694,7 +840,6 @@ function $RouteProvider() { return template; } - /** * @returns {Object} the current active route, by matching it against the URL */ @@ -734,6 +879,14 @@ function $RouteProvider() { }]; } +instantiateRoute.$inject = ['$injector']; +function instantiateRoute($injector) { + if (isEagerInstantiationEnabled) { + // Instantiate `$route` + $injector.get('$route'); + } +} + ngRouteModule.provider('$routeParams', $RouteParamsProvider); @@ -741,6 +894,7 @@ ngRouteModule.provider('$routeParams', $RouteParamsProvider); * @ngdoc service * @name $routeParams * @requires $route + * @this * * @description * The `$routeParams` service allows you to retrieve the current set of route parameters. @@ -800,13 +954,6 @@ ngRouteModule.directive('ngView', ngViewFillContentFactory); * * The enter and leave animation occur concurrently. * - * @knownIssue If `ngView` is contained in an asynchronously loaded template (e.g. in another - * directive's templateUrl or in a template loaded using `ngInclude`), then you need to - * make sure that `$route` is instantiated in time to capture the initial - * `$locationChangeStart` event and load the appropriate view. One way to achieve this - * is to have it as a dependency in a `.run` block: - * `myModule.run(['$route', function() {}]);` - * * @scope * @priority 400 * @param {string=} onload Expression to evaluate whenever the view updates. @@ -917,17 +1064,17 @@ ngRouteModule.directive('ngView', ngViewFillContentFactory); $locationProvider.html5Mode(true); }]) .controller('MainCtrl', ['$route', '$routeParams', '$location', - function($route, $routeParams, $location) { + function MainCtrl($route, $routeParams, $location) { this.$route = $route; this.$location = $location; this.$routeParams = $routeParams; }]) - .controller('BookCtrl', ['$routeParams', function($routeParams) { - this.name = "BookCtrl"; + .controller('BookCtrl', ['$routeParams', function BookCtrl($routeParams) { + this.name = 'BookCtrl'; this.params = $routeParams; }]) - .controller('ChapterCtrl', ['$routeParams', function($routeParams) { - this.name = "ChapterCtrl"; + .controller('ChapterCtrl', ['$routeParams', function ChapterCtrl($routeParams) { + this.name = 'ChapterCtrl'; this.params = $routeParams; }]); @@ -937,15 +1084,15 @@ ngRouteModule.directive('ngView', ngViewFillContentFactory); it('should load and compile correct template', function() { element(by.linkText('Moby: Ch1')).click(); var content = element(by.css('[ng-view]')).getText(); - expect(content).toMatch(/controller\: ChapterCtrl/); - expect(content).toMatch(/Book Id\: Moby/); - expect(content).toMatch(/Chapter Id\: 1/); + expect(content).toMatch(/controller: ChapterCtrl/); + expect(content).toMatch(/Book Id: Moby/); + expect(content).toMatch(/Chapter Id: 1/); element(by.partialLinkText('Scarlet')).click(); content = element(by.css('[ng-view]')).getText(); - expect(content).toMatch(/controller\: BookCtrl/); - expect(content).toMatch(/Book Id\: Scarlet/); + expect(content).toMatch(/controller: BookCtrl/); + expect(content).toMatch(/Book Id: Scarlet/); }); @@ -988,8 +1135,8 @@ function ngViewFactory($route, $anchorScroll, $animate) { } if (currentElement) { previousLeaveAnimation = $animate.leave(currentElement); - previousLeaveAnimation.then(function() { - previousLeaveAnimation = null; + previousLeaveAnimation.done(function(response) { + if (response !== false) previousLeaveAnimation = null; }); currentElement = null; } @@ -1010,8 +1157,8 @@ function ngViewFactory($route, $anchorScroll, $animate) { // function is called before linking the content, which would apply child // directives to non existing elements. var clone = $transclude(newScope, function(clone) { - $animate.enter(clone, null, currentElement || $element).then(function onNgViewEnter() { - if (angular.isDefined(autoScrollExp) + $animate.enter(clone, null, currentElement || $element).done(function onNgViewEnter(response) { + if (response !== false && angular.isDefined(autoScrollExp) && (!autoScrollExp || scope.$eval(autoScrollExp))) { $anchorScroll(); } diff --git a/public/vendor/angular-route/angular-route.min.js b/public/vendor/angular-route/angular-route.min.js index 2fa073f61aa..7d1409b276b 100644 --- a/public/vendor/angular-route/angular-route.min.js +++ b/public/vendor/angular-route/angular-route.min.js @@ -1,16 +1,17 @@ /* - AngularJS v1.5.8 + AngularJS v1.6.1 (c) 2010-2016 Google, Inc. http://angularjs.org License: MIT */ -(function(E,d){'use strict';function y(t,l,g){return{restrict:"ECA",terminal:!0,priority:400,transclude:"element",link:function(b,e,a,c,k){function p(){m&&(g.cancel(m),m=null);h&&(h.$destroy(),h=null);n&&(m=g.leave(n),m.then(function(){m=null}),n=null)}function B(){var a=t.current&&t.current.locals;if(d.isDefined(a&&a.$template)){var a=b.$new(),c=t.current;n=k(a,function(a){g.enter(a,null,n||e).then(function(){!d.isDefined(A)||A&&!b.$eval(A)||l()});p()});h=c.scope=a;h.$emit("$viewContentLoaded"); -h.$eval(s)}else p()}var h,n,m,A=a.autoscroll,s=a.onload||"";b.$on("$routeChangeSuccess",B);B()}}}function w(d,l,g){return{restrict:"ECA",priority:-400,link:function(b,e){var a=g.current,c=a.locals;e.html(c.$template);var k=d(e.contents());if(a.controller){c.$scope=b;var p=l(a.controller,c);a.controllerAs&&(b[a.controllerAs]=p);e.data("$ngControllerController",p);e.children().data("$ngControllerController",p)}b[a.resolveAs||"$resolve"]=c;k(b)}}}var x,C,s=d.module("ngRoute",["ng"]).provider("$route", -function(){function t(b,e){return d.extend(Object.create(b),e)}function l(b,d){var a=d.caseInsensitiveMatch,c={originalPath:b,regexp:b},g=c.keys=[];b=b.replace(/([().])/g,"\\$1").replace(/(\/)?:(\w+)(\*\?|[\?\*])?/g,function(b,a,d,c){b="?"===c||"*?"===c?"?":null;c="*"===c||"*?"===c?"*":null;g.push({name:d,optional:!!b});a=a||"";return""+(b?"":a)+"(?:"+(b?a:"")+(c&&"(.+?)"||"([^/]+)")+(b||"")+")"+(b||"")}).replace(/([\/$\*])/g,"\\$1");c.regexp=new RegExp("^"+b+"$",a?"i":"");return c}x=d.isArray;C= -d.isObject;var g={};this.when=function(b,e){var a;a=void 0;if(x(e)){a=a||[];for(var c=0,k=e.length;c + + + + Model as range: +
    + Model as number:
    + Min:
    + Max:
    + value = {{value}}
    + myForm.range.$valid = {{myForm.range.$valid}}
    + myForm.range.$error = {{myForm.range.$error}} + +
    +
    + + * ## Range Input with ngMin & ngMax attributes + + * @example + + + +
    + Model as range: +
    + Model as number:
    + Min:
    + Max:
    + value = {{value}}
    + myForm.range.$valid = {{myForm.range.$valid}}
    + myForm.range.$error = {{myForm.range.$error}} +
    +
    +
    + + */ + 'range': rangeInputType, /** * @ngdoc input @@ -23947,7 +24509,7 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { function baseInputType(scope, element, attr, ctrl, $sniffer, $browser) { var type = lowercase(element[0].type); - // In composition mode, users are still inputing intermediate text buffer, + // In composition mode, users are still inputting intermediate text buffer, // hold the listener until composition is done. // More about composition events: https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent if (!$sniffer.android) { @@ -24005,7 +24567,7 @@ function baseInputType(scope, element, attr, ctrl, $sniffer, $browser) { } }; - element.on('keydown', function(event) { + element.on('keydown', /** @this */ function(event) { var key = event.keyCode; // ignore @@ -24030,7 +24592,7 @@ function baseInputType(scope, element, attr, ctrl, $sniffer, $browser) { // For these event types, when native validators are present and the browser supports the type, // check for validity changes on various DOM events. if (PARTIAL_VALIDATION_TYPES[type] && ctrl.$$hasNativeValidators && type === attr.type) { - element.on(PARTIAL_VALIDATION_EVENTS, function(ev) { + element.on(PARTIAL_VALIDATION_EVENTS, /** @this */ function(ev) { if (!timeout) { var validity = this[VALIDITY_STATE_PROPERTY]; var origBadInput = validity.badInput; @@ -24098,7 +24660,7 @@ function createDateParser(regexp, mapping) { // When a date is JSON'ified to wraps itself inside of an extra // set of double quotes. This makes the date parsing code unable // to match the date string and parse it as a date. - if (iso.charAt(0) == '"' && iso.charAt(iso.length - 1) == '"') { + if (iso.charAt(0) === '"' && iso.charAt(iso.length - 1) === '"') { iso = iso.substring(1, iso.length - 1); } if (ISO_DATE_REGEXP.test(iso)) { @@ -24140,7 +24702,7 @@ function createDateInputType(type, regexp, parseDate, format) { return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { badInputChecker(scope, element, attr, ctrl); baseInputType(scope, element, attr, ctrl, $sniffer, $browser); - var timezone = ctrl && ctrl.$options && ctrl.$options.timezone; + var timezone = ctrl && ctrl.$options.getOption('timezone'); var previousDate; ctrl.$$parserName = type; @@ -24219,10 +24781,7 @@ function badInputChecker(scope, element, attr, ctrl) { } } -function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { - badInputChecker(scope, element, attr, ctrl); - baseInputType(scope, element, attr, ctrl, $sniffer, $browser); - +function numberFormatterParser(ctrl) { ctrl.$$parserName = 'number'; ctrl.$parsers.push(function(value) { if (ctrl.$isEmpty(value)) return null; @@ -24239,38 +24798,241 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { } return value; }); +} + +function parseNumberAttrVal(val) { + if (isDefined(val) && !isNumber(val)) { + val = parseFloat(val); + } + return !isNumberNaN(val) ? val : undefined; +} + +function isNumberInteger(num) { + // See http://stackoverflow.com/questions/14636536/how-to-check-if-a-variable-is-an-integer-in-javascript#14794066 + // (minus the assumption that `num` is a number) + + // eslint-disable-next-line no-bitwise + return (num | 0) === num; +} + +function countDecimals(num) { + var numString = num.toString(); + var decimalSymbolIndex = numString.indexOf('.'); + + if (decimalSymbolIndex === -1) { + if (-1 < num && num < 1) { + // It may be in the exponential notation format (`1e-X`) + var match = /e-(\d+)$/.exec(numString); + + if (match) { + return Number(match[1]); + } + } + + return 0; + } + + return numString.length - decimalSymbolIndex - 1; +} + +function isValidForStep(viewValue, stepBase, step) { + // At this point `stepBase` and `step` are expected to be non-NaN values + // and `viewValue` is expected to be a valid stringified number. + var value = Number(viewValue); + + // Due to limitations in Floating Point Arithmetic (e.g. `0.3 - 0.2 !== 0.1` or + // `0.5 % 0.1 !== 0`), we need to convert all numbers to integers. + if (!isNumberInteger(value) || !isNumberInteger(stepBase) || !isNumberInteger(step)) { + var decimalCount = Math.max(countDecimals(value), countDecimals(stepBase), countDecimals(step)); + var multiplier = Math.pow(10, decimalCount); + + value = value * multiplier; + stepBase = stepBase * multiplier; + step = step * multiplier; + } + + return (value - stepBase) % step === 0; +} + +function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); + numberFormatterParser(ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + + var minVal; + var maxVal; if (isDefined(attr.min) || attr.ngMin) { - var minVal; ctrl.$validators.min = function(value) { return ctrl.$isEmpty(value) || isUndefined(minVal) || value >= minVal; }; attr.$observe('min', function(val) { - if (isDefined(val) && !isNumber(val)) { - val = parseFloat(val); - } - minVal = isNumber(val) && !isNaN(val) ? val : undefined; + minVal = parseNumberAttrVal(val); // TODO(matsko): implement validateLater to reduce number of validations ctrl.$validate(); }); } if (isDefined(attr.max) || attr.ngMax) { - var maxVal; ctrl.$validators.max = function(value) { return ctrl.$isEmpty(value) || isUndefined(maxVal) || value <= maxVal; }; attr.$observe('max', function(val) { - if (isDefined(val) && !isNumber(val)) { - val = parseFloat(val); - } - maxVal = isNumber(val) && !isNaN(val) ? val : undefined; + maxVal = parseNumberAttrVal(val); // TODO(matsko): implement validateLater to reduce number of validations ctrl.$validate(); }); } + + if (isDefined(attr.step) || attr.ngStep) { + var stepVal; + ctrl.$validators.step = function(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || + isValidForStep(viewValue, minVal || 0, stepVal); + }; + + attr.$observe('step', function(val) { + stepVal = parseNumberAttrVal(val); + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + }); + } +} + +function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); + numberFormatterParser(ctrl); + baseInputType(scope, element, attr, ctrl, $sniffer, $browser); + + var supportsRange = ctrl.$$hasNativeValidators && element[0].type === 'range', + minVal = supportsRange ? 0 : undefined, + maxVal = supportsRange ? 100 : undefined, + stepVal = supportsRange ? 1 : undefined, + validity = element[0].validity, + hasMinAttr = isDefined(attr.min), + hasMaxAttr = isDefined(attr.max), + hasStepAttr = isDefined(attr.step); + + var originalRender = ctrl.$render; + + ctrl.$render = supportsRange && isDefined(validity.rangeUnderflow) && isDefined(validity.rangeOverflow) ? + //Browsers that implement range will set these values automatically, but reading the adjusted values after + //$render would cause the min / max validators to be applied with the wrong value + function rangeRender() { + originalRender(); + ctrl.$setViewValue(element.val()); + } : + originalRender; + + if (hasMinAttr) { + ctrl.$validators.min = supportsRange ? + // Since all browsers set the input to a valid value, we don't need to check validity + function noopMinValidator() { return true; } : + // non-support browsers validate the min val + function minValidator(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(minVal) || viewValue >= minVal; + }; + + setInitialValueAndObserver('min', minChange); + } + + if (hasMaxAttr) { + ctrl.$validators.max = supportsRange ? + // Since all browsers set the input to a valid value, we don't need to check validity + function noopMaxValidator() { return true; } : + // non-support browsers validate the max val + function maxValidator(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(maxVal) || viewValue <= maxVal; + }; + + setInitialValueAndObserver('max', maxChange); + } + + if (hasStepAttr) { + ctrl.$validators.step = supportsRange ? + function nativeStepValidator() { + // Currently, only FF implements the spec on step change correctly (i.e. adjusting the + // input element value to a valid value). It's possible that other browsers set the stepMismatch + // validity error instead, so we can at least report an error in that case. + return !validity.stepMismatch; + } : + // ngStep doesn't set the setp attr, so the browser doesn't adjust the input value as setting step would + function stepValidator(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || + isValidForStep(viewValue, minVal || 0, stepVal); + }; + + setInitialValueAndObserver('step', stepChange); + } + + function setInitialValueAndObserver(htmlAttrName, changeFn) { + // interpolated attributes set the attribute value only after a digest, but we need the + // attribute value when the input is first rendered, so that the browser can adjust the + // input value based on the min/max value + element.attr(htmlAttrName, attr[htmlAttrName]); + attr.$observe(htmlAttrName, changeFn); + } + + function minChange(val) { + minVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumberNaN(ctrl.$modelValue)) { + return; + } + + if (supportsRange) { + var elVal = element.val(); + // IE11 doesn't set the el val correctly if the minVal is greater than the element value + if (minVal > elVal) { + elVal = minVal; + element.val(elVal); + } + ctrl.$setViewValue(elVal); + } else { + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } + } + + function maxChange(val) { + maxVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumberNaN(ctrl.$modelValue)) { + return; + } + + if (supportsRange) { + var elVal = element.val(); + // IE11 doesn't set the el val correctly if the maxVal is less than the element value + if (maxVal < elVal) { + element.val(maxVal); + // IE11 and Chrome don't set the value to the minVal when max < min + elVal = maxVal < minVal ? minVal : maxVal; + } + ctrl.$setViewValue(elVal); + } else { + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } + } + + function stepChange(val) { + stepVal = parseNumberAttrVal(val); + // ignore changes before model is initialized + if (isNumberNaN(ctrl.$modelValue)) { + return; + } + + // Some browsers don't adjust the input value correctly, but set the stepMismatch error + if (supportsRange && ctrl.$viewValue !== element.val()) { + ctrl.$setViewValue(element.val()); + } else { + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } + } } function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { @@ -24300,14 +25062,20 @@ function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { } function radioInputType(scope, element, attr, ctrl) { + var doTrim = !attr.ngTrim || trim(attr.ngTrim) !== 'false'; // make the name unique, if not defined if (isUndefined(attr.name)) { element.attr('name', nextUid()); } var listener = function(ev) { + var value; if (element[0].checked) { - ctrl.$setViewValue(attr.value, ev && ev.type); + value = attr.value; + if (doTrim) { + value = trim(value); + } + ctrl.$setViewValue(value, ev && ev.type); } }; @@ -24315,7 +25083,10 @@ function radioInputType(scope, element, attr, ctrl) { ctrl.$render = function() { var value = attr.value; - element[0].checked = (value == ctrl.$viewValue); + if (doTrim) { + value = trim(value); + } + element[0].checked = (value === ctrl.$viewValue); }; attr.$observe('value', ctrl.$render); @@ -24398,6 +25169,20 @@ function checkboxInputType(scope, element, attr, ctrl, $sniffer, $browser, $filt * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. + * + * @knownIssue + * + * When specifying the `placeholder` attribute of `
    - * {{text}} + * {{text}} * * * @@ -30565,7 +31567,7 @@ var ngSwitchDefaultDirective = ngDirective({ * This example shows how to use `NgTransclude` with fallback content, that * is displayed if no transcluded content is provided. * - * + * * * +``` + +Now, you need to instantiate it by [passing a DOM selector](https://github.com/zenorocha/clipboard.js/blob/master/demo/constructor-selector.html#L18), [HTML element](https://github.com/zenorocha/clipboard.js/blob/master/demo/constructor-node.html#L16-L17), or [list of HTML elements](https://github.com/zenorocha/clipboard.js/blob/master/demo/constructor-nodelist.html#L18-L19). + +```js +new Clipboard('.btn'); +``` + +Internally, we need to fetch all elements that matches with your selector and attach event listeners for each one. But guess what? If you have hundreds of matches, this operation can consume a lot of memory. + +For this reason we use [event delegation](http://stackoverflow.com/questions/1687296/what-is-dom-event-delegation) which replaces multiple event listeners with just a single listener. After all, [#perfmatters](https://twitter.com/hashtag/perfmatters). + +# Usage + +We're living a _declarative renaissance_, that's why we decided to take advantage of [HTML5 data attributes](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Using_data_attributes) for better usability. + +### Copy text from another element + +A pretty common use case is to copy content from another element. You can do that by adding a `data-clipboard-target` attribute in your trigger element. + +The value you include on this attribute needs to match another's element selector. + +example-2 + +```html + + + + + +``` + +### Cut text from another element + +Additionally, you can define a `data-clipboard-action` attribute to specify if you want to either `copy` or `cut` content. + +If you omit this attribute, `copy` will be used by default. + +example-3 + +```html + + + + + +``` + +As you may expect, the `cut` action only works on `` or `