Преглед изворни кода

Merge remote-tracking branch 'grafana/master'

Ryan McKinley пре 9 година
родитељ
комит
1ce0d81736
100 измењених фајлова са 1137 додато и 777 уклоњено
  1. 6 5
      .github/ISSUE_TEMPLATE.md
  2. 21 1
      CHANGELOG.md
  3. 2 1
      README.md
  4. 2 0
      docker/blocks/elastic5/elasticsearch.yml
  5. 8 0
      docker/blocks/elastic5/fig
  6. 23 0
      docs/sources/alerting/metrics.md
  7. 1 1
      docs/sources/alerting/notifications.md
  8. 4 1
      docs/sources/alerting/rules.md
  9. 20 0
      docs/sources/datasources/index.md
  10. 1 1
      docs/sources/datasources/influxdb.md
  11. 0 25
      docs/sources/datasources/overview.md
  12. 0 6
      docs/sources/datasources/plugin_api.md
  13. 1 1
      docs/sources/guides/basic_concepts.md
  14. 10 5
      docs/sources/http_api/admin.md
  15. 10 5
      docs/sources/http_api/auth.md
  16. 10 5
      docs/sources/http_api/dashboard.md
  17. 11 5
      docs/sources/http_api/data_source.md
  18. 22 0
      docs/sources/http_api/index.md
  19. 39 5
      docs/sources/http_api/org.md
  20. 11 5
      docs/sources/http_api/other.md
  21. 0 22
      docs/sources/http_api/overview.md
  22. 10 5
      docs/sources/http_api/preferences.md
  23. 10 5
      docs/sources/http_api/snapshot.md
  24. 3 5
      docs/sources/installation/debian.md
  25. 16 4
      docs/sources/installation/rpm.md
  26. 2 2
      docs/sources/installation/windows.md
  27. 6 9
      docs/sources/reference/graph.md
  28. 1 1
      package.json
  29. 6 1
      pkg/api/alerting.go
  30. 1 0
      pkg/api/api.go
  31. 16 1
      pkg/api/app_routes.go
  32. 10 0
      pkg/api/dashboard.go
  33. 45 9
      pkg/api/dataproxy.go
  34. 105 1
      pkg/api/dataproxy_test.go
  35. 53 6
      pkg/api/datasources.go
  36. 6 5
      pkg/api/dtos/alerting.go
  37. 25 16
      pkg/api/dtos/models.go
  38. 3 1
      pkg/api/login_oauth.go
  39. 3 0
      pkg/api/org_users.go
  40. 6 1
      pkg/components/renderer/renderer.go
  41. 24 0
      pkg/components/securejsondata/securejsondata.go
  42. 1 0
      pkg/middleware/middleware.go
  43. 24 50
      pkg/middleware/recovery.go
  44. 30 26
      pkg/models/datasource.go
  45. 4 19
      pkg/models/plugin_settings.go
  46. 17 0
      pkg/services/alerting/commands.go
  47. 4 4
      pkg/services/alerting/conditions/evaluator.go
  48. 12 7
      pkg/services/alerting/conditions/evaluator_test.go
  49. 7 1
      pkg/services/alerting/conditions/query.go
  50. 20 0
      pkg/services/alerting/conditions/reducer.go
  51. 14 0
      pkg/services/alerting/conditions/reducer_test.go
  52. 1 1
      pkg/services/alerting/eval_context.go
  53. 22 5
      pkg/services/alerting/eval_handler.go
  54. 127 5
      pkg/services/alerting/eval_handler_test.go
  55. 1 0
      pkg/services/alerting/interfaces.go
  56. 2 4
      pkg/services/alerting/notifier.go
  57. 26 5
      pkg/services/alerting/rule.go
  58. 3 0
      pkg/services/alerting/scheduler.go
  59. 3 0
      pkg/services/sqlstore/datasource.go
  60. 5 0
      pkg/services/sqlstore/migrations/datasource_mig.go
  61. 8 6
      pkg/setting/setting.go
  62. 2 4
      pkg/tsdb/influxdb/query.go
  63. 10 4
      pkg/tsdb/influxdb/query_test.go
  64. 0 1
      public/app/app.ts
  65. 6 1
      public/app/core/components/grafana_app.ts
  66. 0 1
      public/app/core/core.ts
  67. 0 31
      public/app/core/directives/grafana_version_check.js
  68. 1 1
      public/app/core/directives/ng_model_on_blur.js
  69. 3 3
      public/app/core/utils/kbn.js
  70. 7 0
      public/app/features/alerting/alert_def.ts
  71. 4 0
      public/app/features/alerting/alert_tab_ctrl.ts
  72. 5 5
      public/app/features/alerting/partials/alert_tab.html
  73. 1 1
      public/app/features/dashboard/dashboard_ctrl.ts
  74. 7 3
      public/app/features/dashboard/dynamic_dashboard_srv.ts
  75. 1 3
      public/app/features/dashboard/export/export_modal.ts
  76. 29 8
      public/app/features/dashboard/export/exporter.ts
  77. 8 7
      public/app/features/dashboard/model.ts
  78. 0 282
      public/app/features/dashboard/partials/globalAlerts.html
  79. 1 1
      public/app/features/dashboard/row/add_panel.html
  80. 0 6
      public/app/features/dashboard/row/add_panel.ts
  81. 2 3
      public/app/features/dashboard/row/row_ctrl.ts
  82. 4 0
      public/app/features/dashboard/row/row_model.ts
  83. 6 2
      public/app/features/dashboard/specs/dashboard_srv_specs.ts
  84. 1 3
      public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts
  85. 10 1
      public/app/features/dashboard/specs/exporter_specs.ts
  86. 1 0
      public/app/features/dashboard/submenu/submenu.ts
  87. 8 1
      public/app/features/dashboard/unsavedChangesSrv.js
  88. 1 0
      public/app/features/panel/metrics_panel_ctrl.ts
  89. 9 0
      public/app/features/panel/panel_ctrl.ts
  90. 8 2
      public/app/features/panel/panel_directive.ts
  91. 0 56
      public/app/features/panel/partials/query_editor_row.html
  92. 101 40
      public/app/features/plugins/partials/ds_http_settings.html
  93. 1 1
      public/app/features/templating/adhoc_variable.ts
  94. 1 1
      public/app/features/templating/constant_variable.ts
  95. 1 1
      public/app/features/templating/custom_variable.ts
  96. 4 1
      public/app/features/templating/datasource_variable.ts
  97. 1 1
      public/app/features/templating/editor_ctrl.ts
  98. 1 1
      public/app/features/templating/interval_variable.ts
  99. 1 1
      public/app/features/templating/partials/editor.html
  100. 7 1
      public/app/features/templating/query_variable.ts

+ 6 - 5
.github/ISSUE_TEMPLATE.md

@@ -1,7 +1,5 @@
-* **I'm submitting a ...**
-- [ ] Bug report
-- [ ] Feature request
-- [ ] Question / Support request: **Please do not** open a github issue. [Support Options](http://grafana.org/support/)
+Please prefix your title with [Bug] or [Feature request]
+For question please check [Support Options](http://grafana.org/support/). **Do not** open a github issue
 
 Please include this information:
 - What Grafana version are you using?
@@ -11,7 +9,10 @@ Please include this information:
 - What was the expected result?
 - What happened instead?
 
-**IMPORTANT** If it relates to metric data viz:
+**IMPORTANT** 
+If it relates to *metric data viz*:
 - An image or text representation of your metric query
 - The raw query and response for the network request (check this in chrome dev tools network tab, here you can see metric requests and other request, please include the request body and request response)
 
+If it relates to *alerting*
+- An image of the test execution data fully expanded.

+ 21 - 1
CHANGELOG.md

@@ -1,9 +1,29 @@
-# 4.0-beta2 (unrelased)
+# 4.0-stable (unreleased)
+
+### Bugfixes
+* **Server-side rendering**: Fixed address used when rendering panel via phantomjs and using non default http_addr config [#6660](https://github.com/grafana/grafana/issues/6660)
+* **Graph panel**: Fixed graph panel tooltip sort order issue [#6648](https://github.com/grafana/grafana/issues/6648)
+* **Unsaved changes**: You now navigate to the intended page after saving in the unsaved changes dialog [#6675](https://github.com/grafana/grafana/issues/6675)
+* **TLS Client Auth**: Support for TLS client authentication for datasource proxies [#2316](https://github.com/grafana/grafana/issues/2316)
+* **Alerts out of sync**: Saving dashboards with broken alerts causes sync problem[#6576](https://github.com/grafana/grafana/issues/6576)
+* **Alerting**: Saving an alert with condition "HAS NO DATA" throws an error[#6701](https://github.com/grafana/grafana/issues/6701)
+* **Config**: Improve error message when parsing broken config file [#6731](https://github.com/grafana/grafana/issues/6731)
+* **Table**: Render empty dates as - instead of current date [#6728](https://github.com/grafana/grafana/issues/6728)
+
+# 4.0-beta2 (2016-11-21)
 
 ### Bugfixes
 * **Graph Panel**: Log base scale on right Y-axis had no effect, max value calc was not applied, [#6534](https://github.com/grafana/grafana/issues/6534)
 * **Graph Panel**: Bar width if bars was only used in series override, [#6528](https://github.com/grafana/grafana/issues/6528)
 * **UI/Browser**: Fixed issue with page/view header gradient border not showing in Safari, [#6530](https://github.com/grafana/grafana/issues/6530)
+* **Cloudwatch**: Fixed cloudwatch datasource requesting to many datapoints, [#6544](https://github.com/grafana/grafana/issues/6544)
+* **UX**: Panel Drop zone visible after duplicating panel, and when entering fullscreen/edit view, [#6598](https://github.com/grafana/grafana/issues/6598)
+* **Templating**: Newly added variable was not visible directly only after dashboard reload, [#6622](https://github.com/grafana/grafana/issues/6622)
+
+### Enhancements
+* **Singlestat**: Support repeated template variables in prefix/postfix [#6595](https://github.com/grafana/grafana/issues/6595)
+* **Templating**: Don't persist variable options with refresh option [#6586](https://github.com/grafana/grafana/issues/6586)
+* **Alerting**: Add ability to have OR conditions (and mixing AND & OR) [#6579](https://github.com/grafana/grafana/issues/6579)
 
 # 4.0-beta1 (2016-11-09)
 

+ 2 - 1
README.md

@@ -17,6 +17,7 @@ Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB.
 - [What's New in Grafana 2.1](http://docs.grafana.org/guides/whats-new-in-v2-1/)
 - [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 Beta](http://docs.grafana.org/guides/whats-new-in-v4/)
 
 ## Features
 ### Graphite Target Editor
@@ -78,7 +79,7 @@ the latest master builds [here](http://grafana.org/builds)
 
 ### Dependencies
 
-- Go 1.7
+- Go 1.7.3
 - NodeJS v4+
 
 ### Get Code

+ 2 - 0
docker/blocks/elastic5/elasticsearch.yml

@@ -0,0 +1,2 @@
+script.inline: on
+script.indexed: on

+ 8 - 0
docker/blocks/elastic5/fig

@@ -0,0 +1,8 @@
+# You need to run 'sysctl -w vm.max_map_count=262144' on the host machine
+
+elasticsearch5:
+  image: elasticsearch:5
+  command: elasticsearch
+  ports:
+    - "9200:9200"
+    - "9300:9300"

+ 23 - 0
docs/sources/alerting/metrics.md

@@ -0,0 +1,23 @@
++++
+title = "Alerting Metrics"
+description = "Alerting Metrics Guide"
+keywords = ["Grafana", "alerting", "guide", "metrics"]
+type = "docs"
+[menu.docs]
+name = "Metrics"
+parent = "alerting"
+weight = 2
++++
+
+# Metrics from the alert engine
+
+> Alerting is only available in Grafana v4.0 and above.
+
+The alert engine publish some internal metrics about itself. You can read more about how Grafana published [interal metrics](/installation/configuration/#metrics)
+
+Description | Type | Metric name
+---------- | ----------- | ----------
+Total number of alerts | counter | `alerting.active_alerts`
+Alert execution result | counter | `alerting.result`
+Notifications sent counter | counter | `alerting.notifications_sent`
+Alert execution timer | timer | `alerting.execution_time`

+ 1 - 1
docs/sources/alerting/notifications.md

@@ -98,6 +98,6 @@ Amazon S3 for this and Webdav. So to set that up you need to configure the
 [external image uploader](/installation/configuration/#external-image-storage) in your grafana-server ini
 config file.
 
-This is not an optional requirement, you can get slack and email notifications without setting this up.
+This is an optional requirement, you can get slack and email notifications without setting this up.
 
 

+ 4 - 1
docs/sources/alerting/rules.md

@@ -55,7 +55,10 @@ Currently the only condition type that exists is a `Query` condition that allows
 specify a query letter, time range and an aggregation function. The letter refers to
 a query you already have added in the **Metrics** tab. The result from the query and the aggregation function is
 a single value that is then used in the threshold check. The query used in an alert rule cannot
-contain any template variables. Currently we only support `AND` operator between conditions.
+contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially.
+For example, we have 3 conditions in the following order:
+`condition:A(evaluates to: TRUE) OR condition:B(evaluates to: FALSE) AND condition:C(evaluates to: TRUE)`
+so the result will be calculated as ((TRUE OR FALSE) AND TRUE) = TRUE.
 
 We plan to add other condition types in the future, like `Other Alert`, where you can include the state
 of another alert in your conditions, and `Time Of Day`.

+ 20 - 0
docs/sources/datasources/index.md

@@ -8,4 +8,24 @@ parent = "features"
 weight = 5
 +++
 
+# Data Source Overview
+Grafana supports many different storage backends for your time series data (Data Source). Each Data Source has a specific Query Editor that is customized for the features and capabilities that the particular Data Source exposes.
+
+
+## Querying
+The query language and capabilities of each Data Source are obviously very different. You can combine data from multiple Data Sources onto a single Dashboard, but each Panel is tied to a specific Data Source that belongs to a particular Organization.
+
+## Supported Data Sources
+The following datasources are officially supported:
+
+* [Graphite]({{< relref "graphite.md" >}})
+* [Elasticsearch]({{< relref "elasticsearch.md" >}})
+* [CloudWatch]({{< relref "cloudwatch.md" >}})
+* [InfluxDB]({{< relref "influxdb.md" >}})
+* [OpenTSDB]({{< relref "opentsdb.md" >}})
+* [Prometheus]({{< relref "prometheus.md" >}})
+
+## Data source plugins
+
+Since grafana 3.0 you can install data sources as plugins. Checkout [Grafana.net](https://grafana.net/plugins) for more data sources.
 

+ 1 - 1
docs/sources/datasources/influxdb.md

@@ -116,7 +116,7 @@ the hosts variable only show hosts from the current selected region with a query
 SHOW TAG VALUES WITH KEY = "hostname"  WHERE region =~ /$region/
 ```
 
-> Always you `regex values` or `regex wildcard` for All format or multi select format.
+> Always use `regex values` or `regex wildcard` for All format or multi select format.
 
 ![](/img/docs/influxdb/templating_simple_ex1.png)
 

+ 0 - 25
docs/sources/datasources/overview.md

@@ -1,25 +0,0 @@
-----
-page_title: Data Source Overview
-page_description: Data Source Overview
-page_keywords: grafana, graphite, influxDB, KairosDB, OpenTSDB, Prometheus, documentation
----
-
-# Data Source Overview
-Grafana supports many different storage backends for your time series data (Data Source). Each Data Source has a specific Query Editor that is customized for the features and capabilities that the particular Data Source exposes.
-
-
-## Querying
-The query language and capabilities of each Data Source are obviously very different. You can combine data from multiple Data Sources onto a single Dashboard, but each Panel is tied to a specific Data Source that belongs to a particular Organization.
-
-## Supported Data Sources
-The following datasources are officially supported:
-
-* [Graphite](/datasources/graphite/)
-* [Elasticsearch](/datasources/elasticsearch/)
-* [CloudWatch](/datasources/cloudwatch/)
-* [InfluxDB](/datasources/influxdb/)
-* [OpenTSDB](/datasources/opentsdb/)
-* [KairosDB](/datasources/kairosdb)
-* [Prometheus](/datasources/prometheus)
-
-Grafana can query any Elasticsearch index for annotation events, but at this time, it's not supported for metric queries. Learn more about [annotations](/reference/annotations/#elasticsearch-annotations)

+ 0 - 6
docs/sources/datasources/plugin_api.md

@@ -30,11 +30,5 @@ Even though the data source type name is with lowercase `g`, the directive uses
 that is how angular directives needs to be named in order to match an element with name `<metric-query-editor-graphite />`.
 You also specify the query controller here instead of in the query.editor.html partial like before.
 
-### query.editor.html
-
-This partial needs to be updated, remove the `np-repeat` this is done in the outer partial now,m the query.editor.html
-should only render a single query. Take a look at the Graphite or InfluxDB partials for `query.editor.html` for reference.
-You should also add a `tight-form-item` with `{{target.refId}}`, all queries needs to be assigned a letter (`refId`).
-These query reference letters are going to be utilized in a later feature.
 
 

+ 1 - 1
docs/sources/guides/basic_concepts.md

@@ -29,7 +29,7 @@ Each Organization can have one or more Data Sources.
 
 All Dashboards are owned by a particular Organization.
 
- > Note: It is important to remember that most metric databases to not provide any sort of per-user series authentication. Therefore, in Grafana, Data Sources and Dashboards are available to all Users in a particular Organization.
+ > Note: It is important to remember that most metric databases do not provide any sort of per-user series authentication. Therefore, in Grafana, Data Sources and Dashboards are available to all Users in a particular Organization.
 
 For more details on the user model for Grafana, please refer to [Admin](/reference/admin/)
 

+ 10 - 5
docs/sources/http_api/admin.md

@@ -1,8 +1,13 @@
-----
-page_title: Admin APIs
-page_description: Grafana Admin API Reference
-page_keywords: grafana, admin, http, api, documentation
----
++++
+title = "Admin HTTP API "
+description = "Grafana Admin HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "admin"]
+aliases = ["/http_api/admin/"]
+type = "docs"
+[menu.docs]
+name = "Admin"
+parent = "http_api"
++++
 
 # Admin API
 

+ 10 - 5
docs/sources/http_api/auth.md

@@ -1,8 +1,13 @@
-----
-page_title: Authentication API
-page_description: Grafana HTTP API Reference
-page_keywords: grafana, admin, http, api, documentation
----
++++
+title = "Authentication HTTP API "
+description = "Grafana Authentication HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "authentication"]
+aliases = ["/http_api/authentication/"]
+type = "docs"
+[menu.docs]
+name = "Authentication"
+parent = "http_api"
++++
 
 # Authentication API
 

+ 10 - 5
docs/sources/http_api/dashboard.md

@@ -1,8 +1,13 @@
-----
-page_title: Dashboard API
-page_description: Grafana Dashboard API Reference
-page_keywords: grafana, admin, http, api, documentation, dashboard
----
++++
+title = "Dashboard HTTP API "
+description = "Grafana Dashboard HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "dashboard"]
+aliases = ["/http_api/dashboard/"]
+type = "docs"
+[menu.docs]
+name = "Dashboard"
+parent = "http_api"
++++
 
 # Dashboard API
 

+ 11 - 5
docs/sources/http_api/data_source.md

@@ -1,8 +1,14 @@
-----
-page_title: Data source API
-page_description: Grafana Data source API Reference
-page_keywords: grafana, admin, http, api, documentation, datasource
----
++++
+title = "Data source HTTP API "
+description = "Grafana Data source HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "data source"]
+aliases = ["/http_api/datasource/"]
+type = "docs"
+[menu.docs]
+name = "Data source"
+parent = "http_api"
++++
+
 
 # Data source API
 

+ 22 - 0
docs/sources/http_api/index.md

@@ -1,7 +1,29 @@
 +++
 title = "HTTP API"
+description = "Grafana HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "overview"]
+type = "docs"
 [menu.docs]
 name = "HTTP API"
 identifier = "http_api"
 weight = 9
 +++
+
+
+# HTTP API Reference
+
+The Grafana backend exposes an HTTP API, the same API is used by the frontend to do everything from saving
+dashboards, creating users and updating data sources.
+
+## Supported HTTP APIs:
+
+
+* [Authentication API]({{< relref "auth.md" >}})
+* [Dashboard API]({{< relref "dashboard.md" >}})
+* [Data Source API]({{< relref "data_source.md" >}})
+* [Organisation API]({{< relref "org.md" >}})
+* [User API]({{< relref "user.md" >}})
+* [Admin API]({{< relref "admin.md" >}})
+* [Snapshot API]({{< relref "snapshot.md" >}})
+* [Preferences API]({{< relref "preferences.md" >}})
+* [Other API]({{< relref "other.md" >}})

+ 39 - 5
docs/sources/http_api/org.md

@@ -1,8 +1,14 @@
-----
-page_title: Organisation API
-page_description: Grafana Organisation API Reference
-page_keywords: grafana, admin, http, api, documentation, orgs, organisation
----
++++
+title = "Organisation HTTP API "
+description = "Grafana Organisation HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "organisation"]
+aliases = ["/http_api/organisation/"]
+type = "docs"
+[menu.docs]
+name = "Organisation"
+parent = "http_api"
++++
+
 
 # Organisation API
 
@@ -85,6 +91,34 @@ page_keywords: grafana, admin, http, api, documentation, orgs, organisation
       }
     }
 
+## Create Organisation
+
+`POST /api/org`
+
+**Example Request**:
+
+    POST /api/org HTTP/1.1
+    Accept: application/json
+    Content-Type: application/json
+    Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+    {
+      "name":"New Org."
+    }
+
+
+**Example Response**:
+
+    HTTP/1.1 200
+    Content-Type: application/json
+
+    {
+      "orgId":"1",
+      "message":"Organization created"
+    }
+
+
+
 ## Update current Organisation
 
 `PUT /api/org`

+ 11 - 5
docs/sources/http_api/other.md

@@ -1,8 +1,14 @@
-----
-page_title: Other APIs
-page_description: Grafana Other API Reference
-page_keywords: grafana, admin, http, api, documentation, dashboards
----
++++
+title = "Other HTTP API "
+description = "Grafana Other HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "other"]
+aliases = ["/http_api/other/"]
+type = "docs"
+[menu.docs]
+name = "Other"
+parent = "http_api"
++++
+
 
 # Frontend Settings API
 

+ 0 - 22
docs/sources/http_api/overview.md

@@ -1,22 +0,0 @@
-----
-page_title: HTTP API
-page_description: Grafana HTTP API Reference
-page_keywords: grafana, admin, http, api, documentation
----
-
-# HTTP API Reference
-
-The Grafana backend exposes an HTTP API, the same API is used by the frontend to do everything from saving
-dashboards, creating users and updating data sources.
-
-###Supported HTTP APIs:
-
-* [Authentication API](/http_api/auth/)
-* [Dashboard API](/http_api/dashboard/)
-* [Data Source API](/http_api/data_source/)
-* [Organisation API](/http_api/org/)
-* [User API](/http_api/user/)
-* [Admin API](/http_api/admin/)
-* [Snapshot API](/http_api/snapshot/)
-* [Preferences API](/http_api/preferences/)
-* [Other API](/http_api/other/)

+ 10 - 5
docs/sources/http_api/preferences.md

@@ -1,8 +1,13 @@
-----
-page_title: Preferences API
-page_description: Grafana Preferences API Reference
-page_keywords: grafana, preferences, http, api, documentation
----
++++
+title = "HTTP Preferences API "
+description = "Grafana HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "preferences"]
+aliases = ["/http_api/preferences/"]
+type = "docs"
+[menu.docs]
+name = "Preferences"
+parent = "http_api"
++++
 
 # User and Org Preferences API
 

+ 10 - 5
docs/sources/http_api/snapshot.md

@@ -1,8 +1,13 @@
-----
-page_title: Snapshot API
-page_description: Grafana Snapshot API Reference
-page_keywords: grafana, admin, http, api, documentation, snapshot, dashboard
----
++++
+title = "HTTP Snapshot API "
+description = "Grafana HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "snapshot"]
+aliases = ["/http_api/snapshot/"]
+type = "docs"
+[menu.docs]
+name = "Snapshot"
+parent = "http_api"
++++
 
 # Snapshot API
 

+ 3 - 5
docs/sources/installation/debian.md

@@ -15,7 +15,7 @@ weight = 1
 Description | Download
 ------------ | -------------
 Stable for Debian-based Linux | [3.1.1 (x86-64 deb)](https://grafanarel.s3.amazonaws.com/builds/grafana_3.1.1-1470047149_amd64.deb)
-Latest Beta for Debian-based Linux | [4.0.0-beta1 (x86-64 deb)](https://grafanarel.s3.amazonaws.com/builds/grafana_4.0.0-1478693311beta1_amd64.deb)
+Latest Beta for Debian-based Linux | [4.0.0-beta2 (x86-64 deb)](https://grafanarel.s3.amazonaws.com/builds/grafana_4.0.0-1479719016beta2_amd64.deb)
 
 ## Install Stable
 
@@ -27,9 +27,9 @@ $ sudo dpkg -i grafana_3.1.1-1470047149_amd64.deb
 
 ## Install Latest Beta
 
-    $ wget https://grafanarel.s3.amazonaws.com/builds/grafana_4.0.0-1478693311beta1_amd64.deb
+    $ wget https://grafanarel.s3.amazonaws.com/builds/grafana_4.0.0-1479719016beta2_amd64.deb
     $ sudo apt-get install -y adduser libfontconfig
-    $ sudo dpkg -i grafana_4.0.0-1478693311beta1_amd64.deb
+    $ sudo dpkg -i grafana_4.0.0-1479719016beta2_amd64.deb
 
 ## APT Repository
 
@@ -139,5 +139,3 @@ To configure Grafana add a configuration file named `custom.ini` to the
 Start Grafana by executing `./bin/grafana-server web`. The `grafana-server`
 binary needs the working directory to be the root install directory (where the
 binary and the `public` folder is located).
-
-

+ 16 - 4
docs/sources/installation/rpm.md

@@ -15,7 +15,7 @@ weight = 2
 Description | Download
 ------------ | -------------
 Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [3.1.1 (x86-64 rpm)](https://grafanarel.s3.amazonaws.com/builds/grafana-3.1.1-1470047149.x86_64.rpm)
-Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [4.0.0-beta1 (x86-64 rpm)](https://grafanarel.s3.amazonaws.com/builds/grafana-4.0.0-1478693311beta1.x86_64.rpm)
+Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [4.0.0-beta2 (x86-64 rpm)](https://grafanarel.s3.amazonaws.com/builds/grafana-4.0.0-1479719016beta2.x86_64.rpm)
 
 ## Install Stable
 
@@ -36,18 +36,18 @@ Or install manually using `rpm`.
 
 ## Or Install Latest Beta
 
-    $ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-4.0.0-1478693311beta1.x86_64.rpm
+    $ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-4.0.0-1479719016beta2.x86_64.rpm
 
 Or install manually using `rpm`.
 
 #### On CentOS / Fedora / Redhat:
 
     $ sudo yum install initscripts fontconfig
-    $ sudo rpm -Uvh grafana-4.0.0-1478693311beta1.x86_64.rpm
+    $ sudo rpm -Uvh grafana-4.0.0-1479719016beta2.x86_64.rpm
 
 #### On OpenSuse:
 
-    $ sudo rpm -i --nodeps grafana-4.0.0-1478693311beta1.x86_64.rpm
+    $ sudo rpm -i --nodeps grafana-4.0.0-1479719016beta2.x86_64.rpm
 
 ## Install via YUM Repository
 
@@ -141,6 +141,18 @@ those options.
 - [OpenTSDB]({{< relref "datasources/opentsdb.md" >}})
 - [Prometheus]({{< relref "datasources/prometheus.md" >}})
 
+### Server side image rendering
+
+Server side image (png) rendering is a feature that is optional but very useful when sharing visualizations,
+for example in alert notifications.
+
+If the image is missing text make sure you have font packages installed.
+
+```
+yum install fontconfig
+yum install freetype*
+yum install urw-fonts
+```
 
 ## Installing from binary tar file
 

+ 2 - 2
docs/sources/installation/windows.md

@@ -13,8 +13,8 @@ weight = 3
 
 Description | Download
 ------------ | -------------
-Latest stable package for Windows | [grafana.3.1.1.windows-x64.zip](https://grafanarel.s3.amazonaws.com/winbuilds/dist/grafana-3.1.1.windows-x64.zip)
-Latest beta package for Windows | [grafana.4.0.0-beta1.windows-x64.zip](https://grafanarel.s3.amazonaws.com/builds/grafana-4.0.0-beta1.windows-x64.zip)
+Latest stable package for Windows | [grafana.3.1.1.windows-x64.zip](https://grafanarel.s3.amazonaws.com/builds/grafana-3.1.1.windows-x64.zip)
+Latest beta package for Windows | [grafana.4.0.0-beta2.windows-x64.zip](https://grafanarel.s3.amazonaws.com/builds/grafana-4.0.0-beta2.windows-x64.zip)
 
 
 ## Configure

+ 6 - 9
docs/sources/reference/graph.md

@@ -48,12 +48,6 @@ populate the template variable to a desired value from the link.
 The metrics tab defines what series data and sources to render.  Each datasource provides different
 options.
 
-### Graphite
-
-### InfluxDB
-
-### OpenTSDB
-
 ## Axes & Grid
 
 ![](/img/docs/v2/graph_axes_grid_options.png)
@@ -71,9 +65,6 @@ The ``Left Y`` and ``Right Y`` can be customized using:
 
 Axes can also be hidden by unchecking the appropriate box from `Show Axis`.
 
-Thresholds allow you to add arbitrary lines or sections to the graph to make it easier to see when
-the graph crosses a particular threshold.
-
 ### Legend
 
 The legend hand be hidden by checking the ``Show`` checkbox.  If it's shown, it can be
@@ -103,6 +94,12 @@ It is just the sum of all data points received by Grafana.
 
 Display styles controls properties of the graph.
 
+### Thresholds
+
+Thresholds allow you to add arbitrary lines or sections to the graph to make it easier to see when
+the graph crosses a particular threshold.
+
+
 ### Chart Options
 
 - ``Bar`` - Display values as a bar chart

+ 1 - 1
package.json

@@ -4,7 +4,7 @@
     "company": "Coding Instinct AB"
   },
   "name": "grafana",
-  "version": "4.0.0-beta1",
+  "version": "4.0.0-beta2",
   "repository": {
     "type": "git",
     "url": "http://github.com/grafana/grafana.git"

+ 6 - 1
pkg/api/alerting.go

@@ -103,6 +103,10 @@ func GetAlerts(c *middleware.Context) Response {
 
 // POST /api/alerts/test
 func AlertTest(c *middleware.Context, dto dtos.AlertTestCommand) Response {
+	if _, idErr := dto.Dashboard.Get("id").Int64(); idErr != nil {
+		return ApiError(400, "The dashboard needs to be saved at least once before you can test an alert rule", nil)
+	}
+
 	backendCmd := alerting.AlertTestCommand{
 		OrgId:     c.OrgId,
 		Dashboard: dto.Dashboard,
@@ -119,7 +123,8 @@ func AlertTest(c *middleware.Context, dto dtos.AlertTestCommand) Response {
 	res := backendCmd.Result
 
 	dtoRes := &dtos.AlertTestResult{
-		Firing: res.Firing,
+		Firing:         res.Firing,
+		ConditionEvals: res.ConditionEvals,
 	}
 
 	if res.Error != nil {

+ 1 - 0
pkg/api/api.go

@@ -307,4 +307,5 @@ func Register(r *macaron.Macaron) {
 
 	InitAppPluginRoutes(r)
 
+	r.NotFound(NotFoundHandler)
 }

+ 16 - 1
pkg/api/app_routes.go

@@ -1,6 +1,11 @@
 package api
 
 import (
+	"crypto/tls"
+	"net"
+	"net/http"
+	"time"
+
 	"gopkg.in/macaron.v1"
 
 	"github.com/grafana/grafana/pkg/api/pluginproxy"
@@ -11,6 +16,16 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
+var pluginProxyTransport = &http.Transport{
+	TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+	Proxy:           http.ProxyFromEnvironment,
+	Dial: (&net.Dialer{
+		Timeout:   30 * time.Second,
+		KeepAlive: 30 * time.Second,
+	}).Dial,
+	TLSHandshakeTimeout: 10 * time.Second,
+}
+
 func InitAppPluginRoutes(r *macaron.Macaron) {
 	for _, plugin := range plugins.Apps {
 		for _, route := range plugin.Routes {
@@ -40,7 +55,7 @@ func AppPluginRoute(route *plugins.AppPluginRoute, appId string) macaron.Handler
 		path := c.Params("*")
 
 		proxy := pluginproxy.NewApiPluginProxy(c, path, route, appId)
-		proxy.Transport = dataProxyTransport
+		proxy.Transport = pluginProxyTransport
 		proxy.ServeHTTP(c.Resp, c.Req.Request)
 	}
 }

+ 10 - 0
pkg/api/dashboard.go

@@ -131,6 +131,16 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 		}
 	}
 
+	validateAlertsCmd := alerting.ValidateDashboardAlertsCommand{
+		OrgId:     c.OrgId,
+		UserId:    c.UserId,
+		Dashboard: dash,
+	}
+
+	if err := bus.Dispatch(&validateAlertsCmd); err != nil {
+		return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
+	}
+
 	err := bus.Dispatch(&cmd)
 	if err != nil {
 		if err == m.ErrDashboardWithSameNameExists {

+ 45 - 9
pkg/api/dataproxy.go

@@ -2,6 +2,7 @@ package api
 
 import (
 	"crypto/tls"
+	"crypto/x509"
 	"net"
 	"net/http"
 	"net/http/httputil"
@@ -17,14 +18,45 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
-var dataProxyTransport = &http.Transport{
-	TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
-	Proxy:           http.ProxyFromEnvironment,
-	Dial: (&net.Dialer{
-		Timeout:   30 * time.Second,
-		KeepAlive: 30 * time.Second,
-	}).Dial,
-	TLSHandshakeTimeout: 10 * time.Second,
+func DataProxyTransport(ds *m.DataSource) (*http.Transport, error) {
+	transport := &http.Transport{
+		TLSClientConfig: &tls.Config{
+			InsecureSkipVerify: true,
+		},
+		Proxy: http.ProxyFromEnvironment,
+		Dial: (&net.Dialer{
+			Timeout:   30 * time.Second,
+			KeepAlive: 30 * time.Second,
+		}).Dial,
+		TLSHandshakeTimeout: 10 * time.Second,
+	}
+
+	var tlsAuth, tlsAuthWithCACert bool
+	if ds.JsonData != nil {
+		tlsAuth = ds.JsonData.Get("tlsAuth").MustBool(false)
+		tlsAuthWithCACert = ds.JsonData.Get("tlsAuthWithCACert").MustBool(false)
+	}
+
+	if tlsAuth {
+		transport.TLSClientConfig.InsecureSkipVerify = false
+
+		decrypted := ds.SecureJsonData.Decrypt()
+
+		if tlsAuthWithCACert && len(decrypted["tlsCACert"]) > 0 {
+			caPool := x509.NewCertPool()
+			ok := caPool.AppendCertsFromPEM([]byte(decrypted["tlsCACert"]))
+			if ok {
+				transport.TLSClientConfig.RootCAs = caPool
+			}
+		}
+
+		cert, err := tls.X509KeyPair([]byte(decrypted["tlsClientCert"]), []byte(decrypted["tlsClientKey"]))
+		if err != nil {
+			return nil, err
+		}
+		transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
+	}
+	return transport, nil
 }
 
 func NewReverseProxy(ds *m.DataSource, proxyPath string, targetUrl *url.URL) *httputil.ReverseProxy {
@@ -128,7 +160,11 @@ func ProxyDataSourceRequest(c *middleware.Context) {
 	}
 
 	proxy := NewReverseProxy(ds, proxyPath, targetUrl)
-	proxy.Transport = dataProxyTransport
+	proxy.Transport, err = DataProxyTransport(ds)
+	if err != nil {
+		c.JsonApiErr(400, "Unable to load TLS certificate", err)
+		return
+	}
 	proxy.ServeHTTP(c.Resp, c.Req.Request)
 	c.Resp.Header().Del("Set-Cookie")
 }

+ 105 - 1
pkg/api/dataproxy_test.go

@@ -7,15 +7,24 @@ import (
 
 	. "github.com/smartystreets/goconvey/convey"
 
+	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
 )
 
 func TestDataSourceProxy(t *testing.T) {
 
 	Convey("When getting graphite datasource proxy", t, func() {
 		ds := m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE}
-		targetUrl, _ := url.Parse(ds.Url)
+		targetUrl, err := url.Parse(ds.Url)
 		proxy := NewReverseProxy(&ds, "/render", targetUrl)
+		proxy.Transport, err = DataProxyTransport(&ds)
+		So(err, ShouldBeNil)
+
+		transport, ok := proxy.Transport.(*http.Transport)
+		So(ok, ShouldBeTrue)
+		So(transport.TLSClientConfig.InsecureSkipVerify, ShouldBeTrue)
 
 		requestUrl, _ := url.Parse("http://grafana.com/sub")
 		req := http.Request{URL: requestUrl}
@@ -54,7 +63,102 @@ func TestDataSourceProxy(t *testing.T) {
 			So(queryVals["u"][0], ShouldEqual, "user")
 			So(queryVals["p"][0], ShouldEqual, "password")
 		})
+	})
 
+	Convey("When getting kubernetes datasource proxy", t, func() {
+		setting.SecretKey = "password"
+
+		json := simplejson.New()
+		json.Set("tlsAuth", true)
+		json.Set("tlsAuthWithCACert", true)
+		ds := m.DataSource{
+			Url:      "htttp://k8s:8001",
+			Type:     "Kubernetes",
+			JsonData: json,
+			SecureJsonData: map[string][]byte{
+				"tlsCACert":     util.Encrypt([]byte(caCert), "password"),
+				"tlsClientCert": util.Encrypt([]byte(clientCert), "password"),
+				"tlsClientKey":  util.Encrypt([]byte(clientKey), "password"),
+			},
+		}
+		targetUrl, err := url.Parse(ds.Url)
+		proxy := NewReverseProxy(&ds, "", targetUrl)
+		proxy.Transport, err = DataProxyTransport(&ds)
+		So(err, ShouldBeNil)
+
+		transport, ok := proxy.Transport.(*http.Transport)
+
+		Convey("Should add cert", func() {
+			So(ok, ShouldBeTrue)
+			So(transport.TLSClientConfig.InsecureSkipVerify, ShouldEqual, false)
+			So(len(transport.TLSClientConfig.Certificates), ShouldEqual, 1)
+		})
 	})
 
 }
+
+const caCert string = `-----BEGIN CERTIFICATE-----
+MIIDATCCAemgAwIBAgIJAMQ5hC3CPDTeMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV
+BAMMDGNhLWs4cy1zdGhsbTAeFw0xNjEwMjcwODQyMjdaFw00NDAzMTQwODQyMjda
+MBcxFTATBgNVBAMMDGNhLWs4cy1zdGhsbTCCASIwDQYJKoZIhvcNAQEBBQADggEP
+ADCCAQoCggEBAMLe2AmJ6IleeUt69vgNchOjjmxIIxz5sp1vFu94m1vUip7CqnOg
+QkpUsHeBPrGYv8UGloARCL1xEWS+9FVZeXWQoDmbC0SxXhFwRIESNCET7Q8KMi/4
+4YPvnMLGZi3Fjwxa8BdUBCN1cx4WEooMVTWXm7RFMtZgDfuOAn3TNXla732sfT/d
+1HNFrh48b0wA+HhmA3nXoBnBEblA665hCeo7lIAdRr0zJxJpnFnWXkyTClsAUTMN
+iL905LdBiiIRenojipfKXvMz88XSaWTI7JjZYU3BvhyXndkT6f12cef3I96NY3WJ
+0uIK4k04WrbzdYXMU3rN6NqlvbHqnI+E7aMCAwEAAaNQME4wHQYDVR0OBBYEFHHx
+2+vSPw9bECHj3O51KNo5VdWOMB8GA1UdIwQYMBaAFHHx2+vSPw9bECHj3O51KNo5
+VdWOMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAH2eV5NcV3LBJHs9
+I+adbiTPg2vyumrGWwy73T0X8Dtchgt8wU7Q9b9Ucg2fOTmSSyS0iMqEu1Yb2ORB
+CknM9mixHC9PwEBbkGCom3VVkqdLwSP6gdILZgyLoH4i8sTUz+S1yGPepi+Vzhs7
+adOXtryjcGnwft6HdfKPNklMOHFnjw6uqpho54oj/z55jUpicY/8glDHdrr1bh3k
+MHuiWLGewHXPvxfG6UoUx1te65IhifVcJGFZDQwfEmhBflfCmtAJlZEsgTLlBBCh
+FHoXIyGOdq1chmRVocdGBCF8fUoGIbuF14r53rpvcbEKtKnnP8+96luKAZLq0a4n
+3lb92xM=
+-----END CERTIFICATE-----`
+
+const clientCert string = `-----BEGIN CERTIFICATE-----
+MIICsjCCAZoCCQCcd8sOfstQLzANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDDAxj
+YS1rOHMtc3RobG0wHhcNMTYxMTAyMDkyNTE1WhcNMTcxMTAyMDkyNTE1WjAfMR0w
+GwYDVQQDDBRhZG0tZGFuaWVsLWs4cy1zdGhsbTCCASIwDQYJKoZIhvcNAQEBBQAD
+ggEPADCCAQoCggEBAOMliaWyNEUJKM37vWCl5bGub3lMicyRAqGQyY/qxD9yKKM2
+FbucVcmWmg5vvTqQVl5rlQ+c7GI8OD6ptmFl8a26coEki7bFr8bkpSyBSEc5p27b
+Z0ORFSqBHWHQbr9PkxPLYW6T3gZYUtRYv3OQgGxLXlvUh85n/mQfuR3N1FgmShHo
+GtAFi/ht6leXa0Ms+jNSDLCmXpJm1GIEqgyKX7K3+g3vzo9coYqXq4XTa8Efs2v8
+SCwqWfBC3rHfgs/5DLB8WT4Kul8QzxkytzcaBQfRfzhSV6bkgm7oTzt2/1eRRsf4
+YnXzLE9YkCC9sAn+Owzqf+TYC1KRluWDfqqBTJUCAwEAATANBgkqhkiG9w0BAQsF
+AAOCAQEAdMsZg6edWGC+xngizn0uamrUg1ViaDqUsz0vpzY5NWLA4MsBc4EtxWRP
+ueQvjUimZ3U3+AX0YWNLIrH1FCVos2jdij/xkTUmHcwzr8rQy+B17cFi+a8jtpgw
+AU6WWoaAIEhhbWQfth/Diz3mivl1ARB+YqiWca2mjRPLTPcKJEURDVddQ423el0Q
+4JNxS5icu7T2zYTYHAo/cT9zVdLZl0xuLxYm3asK1IONJ/evxyVZima3il6MPvhe
+58Hwz+m+HdqHxi24b/1J/VKYbISG4huOQCdLzeNXgvwFlGPUmHSnnKo1/KbQDAR5
+llG/Sw5+FquFuChaA6l5KWy7F3bQyA==
+-----END CERTIFICATE-----`
+
+const clientKey string = `-----BEGIN RSA PRIVATE KEY-----
+MIIEpQIBAAKCAQEA4yWJpbI0RQkozfu9YKXlsa5veUyJzJECoZDJj+rEP3IoozYV
+u5xVyZaaDm+9OpBWXmuVD5zsYjw4Pqm2YWXxrbpygSSLtsWvxuSlLIFIRzmnbttn
+Q5EVKoEdYdBuv0+TE8thbpPeBlhS1Fi/c5CAbEteW9SHzmf+ZB+5Hc3UWCZKEega
+0AWL+G3qV5drQyz6M1IMsKZekmbUYgSqDIpfsrf6De/Oj1yhiperhdNrwR+za/xI
+LCpZ8ELesd+Cz/kMsHxZPgq6XxDPGTK3NxoFB9F/OFJXpuSCbuhPO3b/V5FGx/hi
+dfMsT1iQIL2wCf47DOp/5NgLUpGW5YN+qoFMlQIDAQABAoIBAQCzy4u312XeW1Cs
+Mx6EuOwmh59/ESFmBkZh4rxZKYgrfE5EWlQ7i5SwG4BX+wR6rbNfy6JSmHDXlTkk
+CKvvToVNcW6fYHEivDnVojhIERFIJ4+rhQmpBtcNLOQ3/4cZ8X/GxE6b+3lb5l+x
+64mnjPLKRaIr5/+TVuebEy0xNTJmjnJ7yiB2HRz7uXEQaVSk/P7KAkkyl/9J3/LM
+8N9AX1w6qDaNQZ4/P0++1H4SQenosM/b/GqGTomarEk/GE0NcB9rzmR9VCXa7FRh
+WV5jyt9vUrwIEiK/6nUnOkGO8Ei3kB7Y+e+2m6WdaNoU5RAfqXmXa0Q/a0lLRruf
+vTMo2WrBAoGBAPRaK4cx76Q+3SJ/wfznaPsMM06OSR8A3ctKdV+ip/lyKtb1W8Pz
+k8MYQDH7GwPtSu5QD8doL00pPjugZL/ba7X9nAsI+pinyEErfnB9y7ORNEjIYYzs
+DiqDKup7ANgw1gZvznWvb9Ge0WUSXvWS0pFkgootQAf+RmnnbWGH6l6RAoGBAO35
+aGUrLro5u9RD24uSXNU3NmojINIQFK5dHAT3yl0BBYstL43AEsye9lX95uMPTvOQ
+Cqcn42Hjp/bSe3n0ObyOZeXVrWcDFAfE0wwB1BkvL1lpgnFO9+VQORlH4w3Ppnpo
+jcPkR2TFeDaAYtvckhxe/Bk3OnuFmnsQ3VzM75fFAoGBAI6PvS2XeNU+yA3EtA01
+hg5SQ+zlHswz2TMuMeSmJZJnhY78f5mHlwIQOAPxGQXlf/4iP9J7en1uPpzTK3S0
+M9duK4hUqMA/w5oiIhbHjf0qDnMYVbG+V1V+SZ+cPBXmCDihKreGr5qBKnHpkfV8
+v9WL6o1rcRw4wiQvnaV1gsvBAoGBALtzVTczr6gDKCAIn5wuWy+cQSGTsBunjRLX
+xuVm5iEiV+KMYkPvAx/pKzMLP96lRVR3ptyKgAKwl7LFk3u50+zh4gQLr35QH2wL
+Lw7rNc3srAhrItPsFzqrWX6/cGuFoKYVS239l/sZzRppQPXcpb7xVvTp2whHcir0
+Wtnpl+TdAoGAGqKqo2KU3JoY3IuTDUk1dsNAm8jd9EWDh+s1x4aG4N79mwcss5GD
+FF8MbFPneK7xQd8L6HisKUDAUi2NOyynM81LAftPkvN6ZuUVeFDfCL4vCA0HUXLD
++VrOhtUZkNNJlLMiVRJuQKUOGlg8PpObqYbstQAf/0/yFJMRHG82Tcg=
+-----END RSA PRIVATE KEY-----`

+ 53 - 6
pkg/api/datasources.go

@@ -104,17 +104,56 @@ func AddDataSource(c *middleware.Context, cmd m.AddDataSourceCommand) {
 	c.JSON(200, util.DynMap{"message": "Datasource added", "id": cmd.Result.Id})
 }
 
-func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) {
+func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) Response {
 	cmd.OrgId = c.OrgId
 	cmd.Id = c.ParamsInt64(":id")
 
-	err := bus.Dispatch(&cmd)
+	err := fillWithSecureJsonData(&cmd)
 	if err != nil {
-		c.JsonApiErr(500, "Failed to update datasource", err)
-		return
+		return ApiError(500, "Failed to update datasource", err)
+	}
+
+	err = bus.Dispatch(&cmd)
+	if err != nil {
+		return ApiError(500, "Failed to update datasource", err)
 	}
 
-	c.JsonOK("Datasource updated")
+	return Json(200, "Datasource updated")
+}
+
+func fillWithSecureJsonData(cmd *m.UpdateDataSourceCommand) error {
+	if len(cmd.SecureJsonData) == 0 {
+		return nil
+	}
+
+	ds, err := getRawDataSourceById(cmd.Id, cmd.OrgId)
+
+	if err != nil {
+		return err
+	}
+	secureJsonData := ds.SecureJsonData.Decrypt()
+
+	for k, v := range secureJsonData {
+
+		if _, ok := cmd.SecureJsonData[k]; !ok {
+			cmd.SecureJsonData[k] = v
+		}
+	}
+
+	return nil
+}
+
+func getRawDataSourceById(id int64, orgId int64) (*m.DataSource, error) {
+	query := m.GetDataSourceByIdQuery{
+		Id:    id,
+		OrgId: orgId,
+	}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return nil, err
+	}
+
+	return query.Result, nil
 }
 
 // Get /api/datasources/name/:name
@@ -152,7 +191,7 @@ func GetDataSourceIdByName(c *middleware.Context) Response {
 }
 
 func convertModelToDtos(ds *m.DataSource) dtos.DataSource {
-	return dtos.DataSource{
+	dto := dtos.DataSource{
 		Id:                ds.Id,
 		OrgId:             ds.OrgId,
 		Name:              ds.Name,
@@ -169,4 +208,12 @@ func convertModelToDtos(ds *m.DataSource) dtos.DataSource {
 		IsDefault:         ds.IsDefault,
 		JsonData:          ds.JsonData,
 	}
+
+	if len(ds.SecureJsonData) > 0 {
+		dto.TLSAuth.CACertSet = len(ds.SecureJsonData["tlsCACert"]) > 0
+		dto.TLSAuth.ClientCertSet = len(ds.SecureJsonData["tlsClientCert"]) > 0
+		dto.TLSAuth.ClientKeySet = len(ds.SecureJsonData["tlsClientKey"]) > 0
+	}
+
+	return dto
 }

+ 6 - 5
pkg/api/dtos/alerting.go

@@ -35,11 +35,12 @@ type AlertTestCommand struct {
 }
 
 type AlertTestResult struct {
-	Firing      bool                  `json:"firing"`
-	TimeMs      string                `json:"timeMs"`
-	Error       string                `json:"error,omitempty"`
-	EvalMatches []*EvalMatch          `json:"matches,omitempty"`
-	Logs        []*AlertTestResultLog `json:"logs,omitempty"`
+	Firing         bool                  `json:"firing"`
+	ConditionEvals string                `json:"conditionEvals"`
+	TimeMs         string                `json:"timeMs"`
+	Error          string                `json:"error,omitempty"`
+	EvalMatches    []*EvalMatch          `json:"matches,omitempty"`
+	Logs           []*AlertTestResultLog `json:"logs,omitempty"`
 }
 
 type AlertTestResultLog struct {

+ 25 - 16
pkg/api/dtos/models.go

@@ -64,22 +64,31 @@ type DashboardRedirect struct {
 }
 
 type DataSource struct {
-	Id                int64            `json:"id"`
-	OrgId             int64            `json:"orgId"`
-	Name              string           `json:"name"`
-	Type              string           `json:"type"`
-	TypeLogoUrl       string           `json:"typeLogoUrl"`
-	Access            m.DsAccess       `json:"access"`
-	Url               string           `json:"url"`
-	Password          string           `json:"password"`
-	User              string           `json:"user"`
-	Database          string           `json:"database"`
-	BasicAuth         bool             `json:"basicAuth"`
-	BasicAuthUser     string           `json:"basicAuthUser"`
-	BasicAuthPassword string           `json:"basicAuthPassword"`
-	WithCredentials   bool             `json:"withCredentials"`
-	IsDefault         bool             `json:"isDefault"`
-	JsonData          *simplejson.Json `json:"jsonData,omitempty"`
+	Id                int64             `json:"id"`
+	OrgId             int64             `json:"orgId"`
+	Name              string            `json:"name"`
+	Type              string            `json:"type"`
+	TypeLogoUrl       string            `json:"typeLogoUrl"`
+	Access            m.DsAccess        `json:"access"`
+	Url               string            `json:"url"`
+	Password          string            `json:"password"`
+	User              string            `json:"user"`
+	Database          string            `json:"database"`
+	BasicAuth         bool              `json:"basicAuth"`
+	BasicAuthUser     string            `json:"basicAuthUser"`
+	BasicAuthPassword string            `json:"basicAuthPassword"`
+	WithCredentials   bool              `json:"withCredentials"`
+	IsDefault         bool              `json:"isDefault"`
+	JsonData          *simplejson.Json  `json:"jsonData,omitempty"`
+	SecureJsonData    map[string]string `json:"secureJsonData,omitempty"`
+	TLSAuth           TLSAuth           `json:"tlsAuth,omitempty"`
+}
+
+// TLSAuth is used to show if TLS certs have been uploaded already
+type TLSAuth struct {
+	CACertSet     bool `json:"tlsCACertSet"`
+	ClientCertSet bool `json:"tlsClientCertSet"`
+	ClientKeySet  bool `json:"tlsClientKeySet"`
 }
 
 type DataSourceList []DataSource

+ 3 - 1
pkg/api/login_oauth.go

@@ -96,7 +96,7 @@ func OAuthLogin(ctx *middleware.Context) {
 		}
 		sslcli := &http.Client{Transport: tr}
 
-		oauthCtx = context.TODO()
+		oauthCtx = context.Background()
 		oauthCtx = context.WithValue(oauthCtx, oauth2.HTTPClient, sslcli)
 	}
 
@@ -106,6 +106,8 @@ func OAuthLogin(ctx *middleware.Context) {
 		ctx.Handle(500, "login.OAuthLogin(NewTransportWithCode)", err)
 		return
 	}
+	// token.TokenType was defaulting to "bearer", which is out of spec, so we explicitly set to "Bearer"
+	token.TokenType = "Bearer"
 
 	ctx.Logger.Debug("OAuthLogin Got token")
 

+ 3 - 0
pkg/api/org_users.go

@@ -38,6 +38,9 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response {
 	cmd.UserId = userToAdd.Id
 
 	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrOrgUserAlreadyAdded {
+			return ApiError(409, "User is already member of this organization", nil)
+		}
 		return ApiError(500, "Could not add user to organization", err)
 	}
 

+ 6 - 1
pkg/components/renderer/renderer.go

@@ -35,7 +35,12 @@ func RenderToPng(params *RenderOpts) (string, error) {
 		executable = executable + ".exe"
 	}
 
-	url := fmt.Sprintf("%s://localhost:%s/%s", setting.Protocol, setting.HttpPort, params.Path)
+	localAddress := "localhost"
+	if setting.HttpAddr != setting.DEFAULT_HTTP_ADDR {
+		localAddress = setting.HttpAddr
+	}
+
+	url := fmt.Sprintf("%s://%s:%s/%s", setting.Protocol, localAddress, setting.HttpPort, params.Path)
 
 	binPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, executable))
 	scriptPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, "render.js"))

+ 24 - 0
pkg/components/securejsondata/securejsondata.go

@@ -0,0 +1,24 @@
+package securejsondata
+
+import (
+	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+type SecureJsonData map[string][]byte
+
+func (s SecureJsonData) Decrypt() map[string]string {
+	decrypted := make(map[string]string)
+	for key, data := range s {
+		decrypted[key] = string(util.Decrypt(data, setting.SecretKey))
+	}
+	return decrypted
+}
+
+func GetEncryptedJsonData(sjd map[string]string) SecureJsonData {
+	encrypted := make(SecureJsonData)
+	for key, data := range sjd {
+		encrypted[key] = util.Encrypt([]byte(data), setting.SecretKey)
+	}
+	return encrypted
+}

+ 1 - 0
pkg/middleware/middleware.go

@@ -187,6 +187,7 @@ func (ctx *Context) Handle(status int, title string, err error) {
 	}
 
 	ctx.Data["Title"] = title
+	ctx.Data["AppSubUrl"] = setting.AppSubUrl
 	ctx.HTML(status, strconv.Itoa(status))
 }
 

+ 24 - 50
pkg/middleware/recovery.go

@@ -19,53 +19,14 @@ import (
 	"bytes"
 	"fmt"
 	"io/ioutil"
-	"net/http"
 	"runtime"
 
 	"gopkg.in/macaron.v1"
 
-	"github.com/go-macaron/inject"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/setting"
 )
 
-const (
-	panicHtml = `<html>
-<head><title>PANIC: %s</title>
-<meta charset="utf-8" />
-<style type="text/css">
-html, body {
-	font-family: "Roboto", sans-serif;
-	color: #333333;
-	background-color: #ea5343;
-	margin: 0px;
-}
-h1 {
-	color: #d04526;
-	background-color: #ffffff;
-	padding: 20px;
-	border-bottom: 1px dashed #2b3848;
-}
-pre {
-	margin: 20px;
-	padding: 20px;
-	border: 2px solid #2b3848;
-	background-color: #ffffff;
-	white-space: pre-wrap;       /* css-3 */
-	white-space: -moz-pre-wrap;  /* Mozilla, since 1999 */
-	white-space: -pre-wrap;      /* Opera 4-6 */
-	white-space: -o-pre-wrap;    /* Opera 7 */
-	word-wrap: break-word;       /* Internet Explorer 5.5+ */
-}
-</style>
-</head><body>
-<h1>PANIC</h1>
-<pre style="font-weight: bold;">%s</pre>
-<pre>%s</pre>
-</body>
-</html>`
-)
-
 var (
 	dunno     = []byte("???")
 	centerDot = []byte("·")
@@ -151,21 +112,34 @@ func Recovery() macaron.Handler {
 
 				panicLogger.Error("Request error", "error", err, "stack", string(stack))
 
-				// Lookup the current responsewriter
-				val := c.GetVal(inject.InterfaceOf((*http.ResponseWriter)(nil)))
-				res := val.Interface().(http.ResponseWriter)
+				c.Data["Title"] = "Server Error"
+				c.Data["AppSubUrl"] = setting.AppSubUrl
 
-				// respond with panic message while in development mode
-				var body []byte
-				if setting.Env == setting.DEV {
-					res.Header().Set("Content-Type", "text/html")
-					body = []byte(fmt.Sprintf(panicHtml, err, err, stack))
+				if theErr, ok := err.(error); ok {
+					c.Data["Title"] = theErr.Error()
 				}
 
-				res.WriteHeader(http.StatusInternalServerError)
-				if nil != body {
-					res.Write(body)
+				if setting.Env == setting.DEV {
+					c.Data["ErrorMsg"] = string(stack)
 				}
+
+				c.HTML(500, "500")
+
+				// // Lookup the current responsewriter
+				// val := c.GetVal(inject.InterfaceOf((*http.ResponseWriter)(nil)))
+				// res := val.Interface().(http.ResponseWriter)
+				//
+				// // respond with panic message while in development mode
+				// var body []byte
+				// if setting.Env == setting.DEV {
+				// 	res.Header().Set("Content-Type", "text/html")
+				// 	body = []byte(fmt.Sprintf(panicHtml, err, err, stack))
+				// }
+				//
+				// res.WriteHeader(http.StatusInternalServerError)
+				// if nil != body {
+				// 	res.Write(body)
+				// }
 			}
 		}()
 

+ 30 - 26
pkg/models/datasource.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"time"
 
+	"github.com/grafana/grafana/pkg/components/securejsondata"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 )
 
@@ -46,6 +47,7 @@ type DataSource struct {
 	WithCredentials   bool
 	IsDefault         bool
 	JsonData          *simplejson.Json
+	SecureJsonData    securejsondata.SecureJsonData
 
 	Created time.Time
 	Updated time.Time
@@ -77,19 +79,20 @@ func IsKnownDataSourcePlugin(dsType string) bool {
 
 // Also acts as api DTO
 type AddDataSourceCommand struct {
-	Name              string           `json:"name" binding:"Required"`
-	Type              string           `json:"type" binding:"Required"`
-	Access            DsAccess         `json:"access" binding:"Required"`
-	Url               string           `json:"url"`
-	Password          string           `json:"password"`
-	Database          string           `json:"database"`
-	User              string           `json:"user"`
-	BasicAuth         bool             `json:"basicAuth"`
-	BasicAuthUser     string           `json:"basicAuthUser"`
-	BasicAuthPassword string           `json:"basicAuthPassword"`
-	WithCredentials   bool             `json:"withCredentials"`
-	IsDefault         bool             `json:"isDefault"`
-	JsonData          *simplejson.Json `json:"jsonData"`
+	Name              string            `json:"name" binding:"Required"`
+	Type              string            `json:"type" binding:"Required"`
+	Access            DsAccess          `json:"access" binding:"Required"`
+	Url               string            `json:"url"`
+	Password          string            `json:"password"`
+	Database          string            `json:"database"`
+	User              string            `json:"user"`
+	BasicAuth         bool              `json:"basicAuth"`
+	BasicAuthUser     string            `json:"basicAuthUser"`
+	BasicAuthPassword string            `json:"basicAuthPassword"`
+	WithCredentials   bool              `json:"withCredentials"`
+	IsDefault         bool              `json:"isDefault"`
+	JsonData          *simplejson.Json  `json:"jsonData"`
+	SecureJsonData    map[string]string `json:"secureJsonData"`
 
 	OrgId int64 `json:"-"`
 
@@ -98,19 +101,20 @@ type AddDataSourceCommand struct {
 
 // Also acts as api DTO
 type UpdateDataSourceCommand struct {
-	Name              string           `json:"name" binding:"Required"`
-	Type              string           `json:"type" binding:"Required"`
-	Access            DsAccess         `json:"access" binding:"Required"`
-	Url               string           `json:"url"`
-	Password          string           `json:"password"`
-	User              string           `json:"user"`
-	Database          string           `json:"database"`
-	BasicAuth         bool             `json:"basicAuth"`
-	BasicAuthUser     string           `json:"basicAuthUser"`
-	BasicAuthPassword string           `json:"basicAuthPassword"`
-	WithCredentials   bool             `json:"withCredentials"`
-	IsDefault         bool             `json:"isDefault"`
-	JsonData          *simplejson.Json `json:"jsonData"`
+	Name              string            `json:"name" binding:"Required"`
+	Type              string            `json:"type" binding:"Required"`
+	Access            DsAccess          `json:"access" binding:"Required"`
+	Url               string            `json:"url"`
+	Password          string            `json:"password"`
+	User              string            `json:"user"`
+	Database          string            `json:"database"`
+	BasicAuth         bool              `json:"basicAuth"`
+	BasicAuthUser     string            `json:"basicAuthUser"`
+	BasicAuthPassword string            `json:"basicAuthPassword"`
+	WithCredentials   bool              `json:"withCredentials"`
+	IsDefault         bool              `json:"isDefault"`
+	JsonData          *simplejson.Json  `json:"jsonData"`
+	SecureJsonData    map[string]string `json:"secureJsonData"`
 
 	OrgId int64 `json:"-"`
 	Id    int64 `json:"-"`

+ 4 - 19
pkg/models/plugin_settings.go

@@ -4,8 +4,7 @@ import (
 	"errors"
 	"time"
 
-	"github.com/grafana/grafana/pkg/setting"
-	"github.com/grafana/grafana/pkg/util"
+	"github.com/grafana/grafana/pkg/components/securejsondata"
 )
 
 var (
@@ -19,23 +18,13 @@ type PluginSetting struct {
 	Enabled        bool
 	Pinned         bool
 	JsonData       map[string]interface{}
-	SecureJsonData SecureJsonData
+	SecureJsonData securejsondata.SecureJsonData
 	PluginVersion  string
 
 	Created time.Time
 	Updated time.Time
 }
 
-type SecureJsonData map[string][]byte
-
-func (s SecureJsonData) Decrypt() map[string]string {
-	decrypted := make(map[string]string)
-	for key, data := range s {
-		decrypted[key] = string(util.Decrypt(data, setting.SecretKey))
-	}
-	return decrypted
-}
-
 // ----------------------
 // COMMANDS
 
@@ -58,12 +47,8 @@ type UpdatePluginSettingVersionCmd struct {
 	OrgId         int64  `json:"-"`
 }
 
-func (cmd *UpdatePluginSettingCmd) GetEncryptedJsonData() SecureJsonData {
-	encrypted := make(SecureJsonData)
-	for key, data := range cmd.SecureJsonData {
-		encrypted[key] = util.Encrypt([]byte(data), setting.SecretKey)
-	}
-	return encrypted
+func (cmd *UpdatePluginSettingCmd) GetEncryptedJsonData() securejsondata.SecureJsonData {
+	return securejsondata.GetEncryptedJsonData(cmd.SecureJsonData)
 }
 
 // ---------------------

+ 17 - 0
pkg/services/alerting/commands.go

@@ -11,8 +11,25 @@ type UpdateDashboardAlertsCommand struct {
 	Dashboard *m.Dashboard
 }
 
+type ValidateDashboardAlertsCommand struct {
+	UserId    int64
+	OrgId     int64
+	Dashboard *m.Dashboard
+}
+
 func init() {
 	bus.AddHandler("alerting", updateDashboardAlerts)
+	bus.AddHandler("alerting", validateDashboardAlerts)
+}
+
+func validateDashboardAlerts(cmd *ValidateDashboardAlertsCommand) error {
+	extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId)
+
+	if _, err := extractor.GetAlerts(); err != nil {
+		return err
+	}
+
+	return nil
 }
 
 func updateDashboardAlerts(cmd *UpdateDashboardAlertsCommand) error {

+ 4 - 4
pkg/services/alerting/conditions/evaluator.go

@@ -17,9 +17,9 @@ type AlertEvaluator interface {
 	Eval(reducedValue null.Float) bool
 }
 
-type NoDataEvaluator struct{}
+type NoValueEvaluator struct{}
 
-func (e *NoDataEvaluator) Eval(reducedValue null.Float) bool {
+func (e *NoValueEvaluator) Eval(reducedValue null.Float) bool {
 	return reducedValue.Valid == false
 }
 
@@ -118,8 +118,8 @@ func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
 		return newRangedEvaluator(typ, model)
 	}
 
-	if typ == "no_data" {
-		return &NoDataEvaluator{}, nil
+	if typ == "no_value" {
+		return &NoValueEvaluator{}, nil
 	}
 
 	return nil, alerting.ValidationError{Reason: "Evaluator invalid evaluator type: " + typ}

+ 12 - 7
pkg/services/alerting/conditions/evaluator_test.go

@@ -44,15 +44,20 @@ func TestEvalutors(t *testing.T) {
 		So(evalutorScenario(`{"type": "outside_range", "params": [100, 1] }`, 50), ShouldBeFalse)
 	})
 
-	Convey("no_data", t, func() {
-		So(evalutorScenario(`{"type": "no_data", "params": [] }`, 50), ShouldBeFalse)
+	Convey("no_value", t, func() {
+		Convey("should be false if serie have values", func() {
+			So(evalutorScenario(`{"type": "no_value", "params": [] }`, 50), ShouldBeFalse)
+		})
 
-		jsonModel, err := simplejson.NewJson([]byte(`{"type": "no_data", "params": [] }`))
-		So(err, ShouldBeNil)
+		Convey("should be true when the serie have no value", func() {
+			jsonModel, err := simplejson.NewJson([]byte(`{"type": "no_value", "params": [] }`))
+			So(err, ShouldBeNil)
 
-		evaluator, err := NewAlertEvaluator(jsonModel)
-		So(err, ShouldBeNil)
+			evaluator, err := NewAlertEvaluator(jsonModel)
+			So(err, ShouldBeNil)
 
-		So(evaluator.Eval(null.FloatFromPtr(nil)), ShouldBeTrue)
+			So(evaluator.Eval(null.FloatFromPtr(nil)), ShouldBeTrue)
+
+		})
 	})
 }

+ 7 - 1
pkg/services/alerting/conditions/query.go

@@ -23,6 +23,7 @@ type QueryCondition struct {
 	Query         AlertQuery
 	Reducer       QueryReducer
 	Evaluator     AlertEvaluator
+	Operator      string
 	HandleRequest tsdb.HandleRequestFunc
 }
 
@@ -72,6 +73,7 @@ func (c *QueryCondition) Eval(context *alerting.EvalContext) (*alerting.Conditio
 	return &alerting.ConditionResult{
 		Firing:      evalMatchCount > 0,
 		NoDataFound: emptySerieCount == len(seriesList),
+		Operator:    c.Operator,
 		EvalMatches: matches,
 	}, nil
 }
@@ -168,8 +170,12 @@ func NewQueryCondition(model *simplejson.Json, index int) (*QueryCondition, erro
 	if err != nil {
 		return nil, err
 	}
-
 	condition.Evaluator = evaluator
+
+	operatorJson := model.Get("operator")
+	operator := operatorJson.Get("type").MustString("and")
+	condition.Operator = operator
+
 	return &condition, nil
 }
 

+ 20 - 0
pkg/services/alerting/conditions/reducer.go

@@ -3,6 +3,8 @@ package conditions
 import (
 	"math"
 
+	"sort"
+
 	"github.com/grafana/grafana/pkg/tsdb"
 	"gopkg.in/guregu/null.v3"
 )
@@ -71,6 +73,24 @@ func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) null.Float {
 				break
 			}
 		}
+	case "median":
+		var values []float64
+		for _, v := range series.Points {
+			if v[0].Valid {
+				allNull = false
+				values = append(values, v[0].Float64)
+			}
+		}
+		if len(values) >= 1 {
+			sort.Float64s(values)
+			length := len(values)
+			if length%2 == 1 {
+				value = values[(length-1)/2]
+			} else {
+				value = (values[(length/2)-1] + values[length/2]) / 2
+			}
+		}
+
 	}
 
 	if allNull {

+ 14 - 0
pkg/services/alerting/conditions/reducer_test.go

@@ -41,6 +41,20 @@ func TestSimpleReducer(t *testing.T) {
 			So(result, ShouldEqual, float64(3000))
 		})
 
+		Convey("median odd amount of numbers", func() {
+			result := testReducer("median", 1, 2, 3000)
+			So(result, ShouldEqual, float64(2))
+		})
+
+		Convey("median even amount of numbers", func() {
+			result := testReducer("median", 1, 2, 4, 3000)
+			So(result, ShouldEqual, float64(3))
+		})
+
+		Convey("median with one values", func() {
+			result := testReducer("median", 1)
+			So(result, ShouldEqual, float64(1))
+		})
 	})
 }
 

+ 1 - 1
pkg/services/alerting/eval_context.go

@@ -17,7 +17,7 @@ type EvalContext struct {
 	EvalMatches     []*EvalMatch
 	Logs            []*ResultLogEntry
 	Error           error
-	Description     string
+	ConditionEvals  string
 	StartTime       time.Time
 	EndTime         time.Time
 	Rule            *Rule

+ 22 - 5
pkg/services/alerting/eval_handler.go

@@ -1,6 +1,8 @@
 package alerting
 
 import (
+	"strconv"
+	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/log"
@@ -21,7 +23,11 @@ func NewEvalHandler() *DefaultEvalHandler {
 
 func (e *DefaultEvalHandler) Eval(context *EvalContext) {
 	firing := true
-	for _, condition := range context.Rule.Conditions {
+	noDataFound := true
+	conditionEvals := ""
+
+	for i := 0; i < len(context.Rule.Conditions); i++ {
+		condition := context.Rule.Conditions[i]
 		cr, err := condition.Eval(context)
 		if err != nil {
 			context.Error = err
@@ -32,16 +38,27 @@ func (e *DefaultEvalHandler) Eval(context *EvalContext) {
 			break
 		}
 
-		// break if result has not triggered yet
-		if cr.Firing == false {
-			firing = false
-			break
+		// calculating Firing based on operator
+		if cr.Operator == "or" {
+			firing = firing || cr.Firing
+			noDataFound = noDataFound || cr.NoDataFound
+		} else {
+			firing = firing && cr.Firing
+			noDataFound = noDataFound && cr.NoDataFound
+		}
+
+		if i > 0 {
+			conditionEvals = "[" + conditionEvals + " " + strings.ToUpper(cr.Operator) + " " + strconv.FormatBool(cr.Firing) + "]"
+		} else {
+			conditionEvals = strconv.FormatBool(firing)
 		}
 
 		context.EvalMatches = append(context.EvalMatches, cr.EvalMatches...)
 	}
 
+	context.ConditionEvals = conditionEvals + " = " + strconv.FormatBool(firing)
 	context.Firing = firing
+	context.NoDataFound = noDataFound
 	context.EndTime = time.Now()
 	elapsedTime := context.EndTime.Sub(context.StartTime) / time.Millisecond
 	metrics.M_Alerting_Exeuction_Time.Update(elapsedTime)

+ 127 - 5
pkg/services/alerting/eval_handler_test.go

@@ -8,12 +8,14 @@ import (
 )
 
 type conditionStub struct {
-	firing  bool
-	matches []*EvalMatch
+	firing   bool
+	operator string
+	matches  []*EvalMatch
+	noData   bool
 }
 
 func (c *conditionStub) Eval(context *EvalContext) (*ConditionResult, error) {
-	return &ConditionResult{Firing: c.firing, EvalMatches: c.matches}, nil
+	return &ConditionResult{Firing: c.firing, EvalMatches: c.matches, Operator: c.operator, NoDataFound: c.noData}, nil
 }
 
 func TestAlertingExecutor(t *testing.T) {
@@ -29,18 +31,138 @@ func TestAlertingExecutor(t *testing.T) {
 
 			handler.Eval(context)
 			So(context.Firing, ShouldEqual, true)
+			So(context.ConditionEvals, ShouldEqual, "true = true")
 		})
 
 		Convey("Show return false with not passing asdf", func() {
 			context := NewEvalContext(context.TODO(), &Rule{
 				Conditions: []Condition{
-					&conditionStub{firing: true, matches: []*EvalMatch{&EvalMatch{}, &EvalMatch{}}},
-					&conditionStub{firing: false},
+					&conditionStub{firing: true, operator: "and", matches: []*EvalMatch{&EvalMatch{}, &EvalMatch{}}},
+					&conditionStub{firing: false, operator: "and"},
 				},
 			})
 
 			handler.Eval(context)
 			So(context.Firing, ShouldEqual, false)
+			So(context.ConditionEvals, ShouldEqual, "[true AND false] = false")
+		})
+
+		Convey("Show return true if any of the condition is passing with OR operator", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{firing: true, operator: "and"},
+					&conditionStub{firing: false, operator: "or"},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.Firing, ShouldEqual, true)
+			So(context.ConditionEvals, ShouldEqual, "[true OR false] = true")
+		})
+
+		Convey("Show return false if any of the condition is failing with AND operator", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{firing: true, operator: "and"},
+					&conditionStub{firing: false, operator: "and"},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.Firing, ShouldEqual, false)
+			So(context.ConditionEvals, ShouldEqual, "[true AND false] = false")
+		})
+
+		Convey("Show return true if one condition is failing with nested OR operator", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{firing: true, operator: "and"},
+					&conditionStub{firing: true, operator: "and"},
+					&conditionStub{firing: false, operator: "or"},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.Firing, ShouldEqual, true)
+			So(context.ConditionEvals, ShouldEqual, "[[true AND true] OR false] = true")
+		})
+
+		Convey("Show return false if one condition is passing with nested OR operator", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{firing: true, operator: "and"},
+					&conditionStub{firing: false, operator: "and"},
+					&conditionStub{firing: false, operator: "or"},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.Firing, ShouldEqual, false)
+			So(context.ConditionEvals, ShouldEqual, "[[true AND false] OR false] = false")
+		})
+
+		Convey("Show return false if a condition is failing with nested AND operator", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{firing: true, operator: "and"},
+					&conditionStub{firing: false, operator: "and"},
+					&conditionStub{firing: true, operator: "and"},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.Firing, ShouldEqual, false)
+			So(context.ConditionEvals, ShouldEqual, "[[true AND false] AND true] = false")
+		})
+
+		Convey("Show return true if a condition is passing with nested OR operator", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{firing: true, operator: "and"},
+					&conditionStub{firing: false, operator: "or"},
+					&conditionStub{firing: true, operator: "or"},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.Firing, ShouldEqual, true)
+			So(context.ConditionEvals, ShouldEqual, "[[true OR false] OR true] = true")
+		})
+
+		Convey("Should return no data if one condition has nodata", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{operator: "and", noData: true},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.Firing, ShouldEqual, false)
+			So(context.NoDataFound, ShouldBeTrue)
+		})
+
+		Convey("Should return no data if both conditions have no data and using AND", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{operator: "and", noData: true},
+					&conditionStub{operator: "and", noData: false},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.NoDataFound, ShouldBeFalse)
+		})
+
+		Convey("Should not return no data if both conditions have no data and using OR", func() {
+			context := NewEvalContext(context.TODO(), &Rule{
+				Conditions: []Condition{
+					&conditionStub{operator: "or", noData: true},
+					&conditionStub{operator: "or", noData: false},
+				},
+			})
+
+			handler.Eval(context)
+			So(context.NoDataFound, ShouldBeTrue)
 		})
 	})
 }

+ 1 - 0
pkg/services/alerting/interfaces.go

@@ -24,6 +24,7 @@ type Notifier interface {
 type ConditionResult struct {
 	Firing      bool
 	NoDataFound bool
+	Operator    string
 	EvalMatches []*EvalMatch
 }
 

+ 2 - 4
pkg/services/alerting/notifier.go

@@ -55,10 +55,8 @@ func (n *RootNotifier) Notify(context *EvalContext) error {
 		return nil
 	}
 
-	err = n.uploadImage(context)
-	if err != nil {
-		n.log.Error("Failed to upload alert panel image", "error", err)
-		return err
+	if err = n.uploadImage(context); err != nil {
+		n.log.Error("Failed to upload alert panel image.", "error", err)
 	}
 
 	return n.sendNotifications(context, notifiers)

+ 26 - 5
pkg/services/alerting/rule.go

@@ -26,11 +26,32 @@ type Rule struct {
 }
 
 type ValidationError struct {
-	Reason string
+	Reason      string
+	Err         error
+	Alertid     int64
+	DashboardId int64
+	PanelId     int64
 }
 
 func (e ValidationError) Error() string {
-	return e.Reason
+	extraInfo := ""
+	if e.Alertid != 0 {
+		extraInfo = fmt.Sprintf("%s AlertId: %v", extraInfo, e.Alertid)
+	}
+
+	if e.PanelId != 0 {
+		extraInfo = fmt.Sprintf("%s PanelId: %v ", extraInfo, e.PanelId)
+	}
+
+	if e.DashboardId != 0 {
+		extraInfo = fmt.Sprintf("%s DashboardId: %v", extraInfo, e.DashboardId)
+	}
+
+	if e.Err != nil {
+		return fmt.Sprintf("%s %s%s", e.Err.Error(), e.Reason, extraInfo)
+	}
+
+	return fmt.Sprintf("Failed to extract alert.Reason: %s %s", e.Reason, extraInfo)
 }
 
 var (
@@ -83,7 +104,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
 	for _, v := range ruleDef.Settings.Get("notifications").MustArray() {
 		jsonModel := simplejson.NewFromAny(v)
 		if id, err := jsonModel.Get("id").Int64(); err != nil {
-			return nil, ValidationError{Reason: "Invalid notification schema"}
+			return nil, ValidationError{Reason: "Invalid notification schema", DashboardId: model.DashboardId, Alertid: model.Id, PanelId: model.PanelId}
 		} else {
 			model.Notifications = append(model.Notifications, id)
 		}
@@ -93,10 +114,10 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
 		conditionModel := simplejson.NewFromAny(condition)
 		conditionType := conditionModel.Get("type").MustString()
 		if factory, exist := conditionFactories[conditionType]; !exist {
-			return nil, ValidationError{Reason: "Unknown alert condition: " + conditionType}
+			return nil, ValidationError{Reason: "Unknown alert condition: " + conditionType, DashboardId: model.DashboardId, Alertid: model.Id, PanelId: model.PanelId}
 		} else {
 			if queryCondition, err := factory(conditionModel, index); err != nil {
-				return nil, err
+				return nil, ValidationError{Err: err, DashboardId: model.DashboardId, Alertid: model.Id, PanelId: model.PanelId}
 			} else {
 				model.Conditions = append(model.Conditions, queryCondition)
 			}

+ 3 - 0
pkg/services/alerting/scheduler.go

@@ -39,6 +39,9 @@ func (s *SchedulerImpl) Update(rules []*Rule) {
 
 		offset := ((rule.Frequency * 1000) / int64(len(rules))) * int64(i)
 		job.Offset = int64(math.Floor(float64(offset) / 1000))
+		if job.Offset == 0 { //zero offset causes division with 0 panics.
+			job.Offset = 1
+		}
 		jobs[rule.Id] = job
 	}
 

+ 3 - 0
pkg/services/sqlstore/datasource.go

@@ -4,6 +4,7 @@ import (
 	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/securejsondata"
 	m "github.com/grafana/grafana/pkg/models"
 
 	"github.com/go-xorm/xorm"
@@ -82,6 +83,7 @@ func AddDataSource(cmd *m.AddDataSourceCommand) error {
 			BasicAuthPassword: cmd.BasicAuthPassword,
 			WithCredentials:   cmd.WithCredentials,
 			JsonData:          cmd.JsonData,
+			SecureJsonData:    securejsondata.GetEncryptedJsonData(cmd.SecureJsonData),
 			Created:           time.Now(),
 			Updated:           time.Now(),
 		}
@@ -128,6 +130,7 @@ func UpdateDataSource(cmd *m.UpdateDataSourceCommand) error {
 			BasicAuthPassword: cmd.BasicAuthPassword,
 			WithCredentials:   cmd.WithCredentials,
 			JsonData:          cmd.JsonData,
+			SecureJsonData:    securejsondata.GetEncryptedJsonData(cmd.SecureJsonData),
 			Updated:           time.Now(),
 		}
 

+ 5 - 0
pkg/services/sqlstore/migrations/datasource_mig.go

@@ -101,4 +101,9 @@ func addDataSourceMigration(mg *Migrator) {
 	mg.AddMigration("Add column with_credentials", NewAddColumnMigration(tableV2, &Column{
 		Name: "with_credentials", Type: DB_Bool, Nullable: false, Default: "0",
 	}))
+
+	// add column that can store TLS client auth data
+	mg.AddMigration("Add secure json data column", NewAddColumnMigration(tableV2, &Column{
+		Name: "secure_json_data", Type: DB_Text, Nullable: true,
+	}))
 }

+ 8 - 6
pkg/setting/setting.go

@@ -24,8 +24,9 @@ import (
 type Scheme string
 
 const (
-	HTTP  Scheme = "http"
-	HTTPS Scheme = "https"
+	HTTP              Scheme = "http"
+	HTTPS             Scheme = "https"
+	DEFAULT_HTTP_ADDR string = "0.0.0.0"
 )
 
 const (
@@ -324,11 +325,12 @@ func loadSpecifedConfigFile(configFile string) error {
 	}
 
 	userConfig, err := ini.Load(configFile)
-	userConfig.BlockMode = false
 	if err != nil {
 		return fmt.Errorf("Failed to parse %v, %v", configFile, err)
 	}
 
+	userConfig.BlockMode = false
+
 	for _, section := range userConfig.Sections() {
 		for _, key := range section.Keys() {
 			if key.Value() == "" {
@@ -359,12 +361,12 @@ func loadConfiguration(args *CommandLineArgs) {
 	configFiles = append(configFiles, defaultConfigFile)
 
 	Cfg, err = ini.Load(defaultConfigFile)
-	Cfg.BlockMode = false
-
 	if err != nil {
 		log.Fatal(3, "Failed to parse defaults.ini, %v", err)
 	}
 
+	Cfg.BlockMode = false
+
 	// command line props
 	commandLineProps := getCommandLineProperties(args.Args)
 	// load default overrides
@@ -474,7 +476,7 @@ func NewConfigContext(args *CommandLineArgs) error {
 	}
 
 	Domain = server.Key("domain").MustString("localhost")
-	HttpAddr = server.Key("http_addr").MustString("0.0.0.0")
+	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)

+ 2 - 4
pkg/tsdb/influxdb/query.go

@@ -2,7 +2,6 @@ package influxdb
 
 import (
 	"fmt"
-	"strconv"
 	"strings"
 
 	"regexp"
@@ -58,13 +57,12 @@ func (query *Query) renderTags() []string {
 		}
 
 		textValue := ""
-		numericValue, err := strconv.ParseFloat(tag.Value, 64)
 
 		// quote value unless regex or number
 		if tag.Operator == "=~" || tag.Operator == "!~" {
 			textValue = tag.Value
-		} else if err == nil {
-			textValue = fmt.Sprintf("%v", numericValue)
+		} else if tag.Operator == "<" || tag.Operator == ">" {
+			textValue = tag.Value
 		} else {
 			textValue = fmt.Sprintf("'%s'", tag.Value)
 		}

+ 10 - 4
pkg/tsdb/influxdb/query_test.go

@@ -106,13 +106,19 @@ func TestInfluxdbQueryBuilder(t *testing.T) {
 		Convey("can render number tags", func() {
 			query := &Query{Tags: []*Tag{&Tag{Operator: "=", Value: "10001", Key: "key"}}}
 
-			So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = 10001`)
+			So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = '10001'`)
 		})
 
-		Convey("can render number tags with decimals", func() {
-			query := &Query{Tags: []*Tag{&Tag{Operator: "=", Value: "10001.1", Key: "key"}}}
+		Convey("can render numbers less then condition tags", func() {
+			query := &Query{Tags: []*Tag{&Tag{Operator: "<", Value: "10001", Key: "key"}}}
 
-			So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" = 10001.1`)
+			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"}}}
+
+			So(strings.Join(query.renderTags(), ""), ShouldEqual, `"key" > 10001`)
 		})
 
 		Convey("can render string tags", func() {

+ 0 - 1
public/app/app.ts

@@ -40,7 +40,6 @@ export class GrafanaApp {
 
   init() {
     var app = angular.module('grafana', []);
-    app.constant('grafanaVersion', "@grafanaVersion@");
 
     moment.locale(config.bootData.user.locale);
 

+ 6 - 1
public/app/core/components/grafana_app.ts

@@ -147,9 +147,14 @@ export function grafanaAppDirective(playlistSrv, contextSrv) {
         }
       }
 
+      // mouse and keyboard is user activity
       body.mousemove(userActivityDetected);
       body.keydown(userActivityDetected);
-      setInterval(checkForInActiveUser, 1000);
+      // treat tab change as activity
+      document.addEventListener('visibilitychange', userActivityDetected);
+
+      // check every 2 seconds
+      setInterval(checkForInActiveUser, 2000);
 
       appEvents.on('toggle-view-mode', () => {
         lastActivity = 0;

+ 0 - 1
public/app/core/core.ts

@@ -6,7 +6,6 @@ import "./directives/dash_class";
 import "./directives/confirm_click";
 import "./directives/dash_edit_link";
 import "./directives/dropdown_typeahead";
-import "./directives/grafana_version_check";
 import "./directives/metric_segment";
 import "./directives/misc";
 import "./directives/ng_model_on_blur";

+ 0 - 31
public/app/core/directives/grafana_version_check.js

@@ -1,31 +0,0 @@
-define([
-  '../core_module',
-],
-function (coreModule) {
-  'use strict';
-
-  coreModule.default.directive('grafanaVersionCheck', function($http, contextSrv) {
-    return {
-      restrict: 'A',
-      link: function(scope, elem) {
-        if (contextSrv.version === 'master') {
-          return;
-        }
-
-        $http({ method: 'GET', url: 'https://grafanarel.s3.amazonaws.com/latest.json' })
-        .then(function(response) {
-          if (!response.data || !response.data.version) {
-            return;
-          }
-
-          if (contextSrv.version !== response.data.version) {
-            elem.append('<i class="icon-info-sign"></i> ' +
-                        '<a href="http://grafana.org/download" target="_blank"> ' +
-            'New version available: ' + response.data.version +
-              '</a>');
-          }
-        });
-      }
-    };
-  });
-});

+ 1 - 1
public/app/core/directives/ng_model_on_blur.js

@@ -47,7 +47,7 @@ function (coreModule, kbn, rangeUtil) {
           if (ctrl.$isEmpty(modelValue)) {
             return true;
           }
-          if (viewValue.indexOf('$') === 0) {
+          if (viewValue.indexOf('$') === 0 || viewValue.indexOf('+$') === 0) {
             return true; // allow template variable
           }
           var info = rangeUtil.describeTextRange(viewValue);

+ 3 - 3
public/app/core/utils/kbn.js

@@ -420,11 +420,11 @@ function($, _, moment) {
   kbn.valueFormats.bps    = kbn.formatBuilders.decimalSIPrefix('bps');
   kbn.valueFormats.Bps    = kbn.formatBuilders.decimalSIPrefix('Bps');
   kbn.valueFormats.KBs    = kbn.formatBuilders.decimalSIPrefix('Bs', 1);
-  kbn.valueFormats.Kbits  = kbn.formatBuilders.decimalSIPrefix('bits', 1);
+  kbn.valueFormats.Kbits  = kbn.formatBuilders.decimalSIPrefix('bps', 1);
   kbn.valueFormats.MBs    = kbn.formatBuilders.decimalSIPrefix('Bs', 2);
-  kbn.valueFormats.Mbits  = kbn.formatBuilders.decimalSIPrefix('bits', 2);
+  kbn.valueFormats.Mbits  = kbn.formatBuilders.decimalSIPrefix('bps', 2);
   kbn.valueFormats.GBs    = kbn.formatBuilders.decimalSIPrefix('Bs', 3);
-  kbn.valueFormats.Gbits  = kbn.formatBuilders.decimalSIPrefix('bits', 3);
+  kbn.valueFormats.Gbits  = kbn.formatBuilders.decimalSIPrefix('bps', 3);
 
   // Throughput
   kbn.valueFormats.ops  = kbn.formatBuilders.simpleCountUnit('ops');

+ 7 - 0
public/app/features/alerting/alert_def.ts

@@ -28,6 +28,11 @@ var evalFunctions = [
   {text: 'HAS NO VALUE' , value: 'no_value'}
 ];
 
+var evalOperators = [
+  {text: 'OR', value: 'or'},
+  {text: 'AND', value: 'and'},
+];
+
 var reducerTypes = [
   {text: 'avg()', value: 'avg'},
   {text: 'min()', value: 'min'},
@@ -35,6 +40,7 @@ var reducerTypes = [
   {text: 'sum()' , value: 'sum'},
   {text: 'count()', value: 'count'},
   {text: 'last()', value: 'last'},
+  {text: 'median()', value: 'median'},
 ];
 
 var noDataModes = [
@@ -116,6 +122,7 @@ export default {
   getStateDisplayModel: getStateDisplayModel,
   conditionTypes: conditionTypes,
   evalFunctions: evalFunctions,
+  evalOperators: evalOperators,
   noDataModes: noDataModes,
   executionErrorModes: executionErrorModes,
   reducerTypes: reducerTypes,

+ 4 - 0
public/app/features/alerting/alert_tab_ctrl.ts

@@ -18,6 +18,7 @@ export class AlertTabCtrl {
   alert: any;
   conditionModels: any;
   evalFunctions: any;
+  evalOperators: any;
   noDataModes: any;
   executionErrorModes: any;
   addNotificationSegment;
@@ -41,6 +42,7 @@ export class AlertTabCtrl {
     this.$scope.ctrl = this;
     this.subTabIndex = 0;
     this.evalFunctions = alertDef.evalFunctions;
+    this.evalOperators = alertDef.evalOperators;
     this.conditionTypes = alertDef.conditionTypes;
     this.noDataModes = alertDef.noDataModes;
     this.executionErrorModes = alertDef.executionErrorModes;
@@ -194,6 +196,7 @@ export class AlertTabCtrl {
       query: {params: ['A', '5m', 'now']},
       reducer: {type: 'avg', params: []},
       evaluator: {type: 'gt', params: [null]},
+      operator: {type: 'and'},
     };
   }
 
@@ -250,6 +253,7 @@ export class AlertTabCtrl {
     cm.queryPart = new QueryPart(source.query, alertDef.alertQueryDef);
     cm.reducerPart = alertDef.createReducerPart(source.reducer);
     cm.evaluator = source.evaluator;
+    cm.operator = source.operator;
 
     return cm;
   }

+ 5 - 5
public/app/features/alerting/partials/alert_tab.html

@@ -38,23 +38,23 @@
 				<h5 class="section-heading">Conditions</h5>
 				<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
 					<div class="gf-form">
-						<span class="gf-form-label query-keyword width-5" ng-if="$index">AND</span>
+						<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model>
 						<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
 					</div>
           <div class="gf-form">
-						<query-part-editor class="gf-form-label query-part" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
+						<query-part-editor class="gf-form-label query-part width-6" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
 						</query-part-editor>
             <span class="gf-form-label query-keyword">OF</span>
 					</div>
 					<div class="gf-form">
-						<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
+						<query-part-editor class="gf-form-label query-part width-10" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
 						</query-part-editor>
 					</div>
 					<div class="gf-form">
 						<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
-						<input class="gf-form-input max-width-7" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()"></input>
+						<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()"></input>
             <label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
-            <input class="gf-form-input max-width-7" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()"></input>
+            <input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()"></input>
 					</div>
 					<div class="gf-form">
 						<label class="gf-form-label">

+ 1 - 1
public/app/features/dashboard/dashboard_ctrl.ts

@@ -52,7 +52,7 @@ export class DashboardCtrl {
         .catch($scope.onInitFailed.bind(this, 'Templating init failed', false))
         // continue
         .finally(function() {
-          dynamicDashboardSrv.init(dashboard, variableSrv);
+          dynamicDashboardSrv.init(dashboard);
           dynamicDashboardSrv.process();
 
           unsavedChangesSrv.init(dashboard, $scope);

+ 7 - 3
public/app/features/dashboard/dynamic_dashboard_srv.ts

@@ -12,12 +12,12 @@ export class DynamicDashboardSrv {
   dashboard: any;
   variables: any;
 
-  init(dashboard, variableSrv) {
+  init(dashboard) {
     this.dashboard = dashboard;
-    this.variables = variableSrv.variables;
+    this.variables = dashboard.templating.list;
   }
 
-  process(options) {
+  process(options?) {
     if (this.dashboard.snapshot || this.variables.length === 0) {
       return;
     }
@@ -31,6 +31,8 @@ export class DynamicDashboardSrv {
     // cleanup scopedVars
     for (i = 0; i < this.dashboard.rows.length; i++) {
       row = this.dashboard.rows[i];
+      delete row.scopedVars;
+
       for (j = 0; j < row.panels.length; j++) {
         delete row.panels[j].scopedVars;
       }
@@ -64,6 +66,8 @@ export class DynamicDashboardSrv {
           j = j - 1;
         }
       }
+
+      row.panelSpanChanged();
     }
   }
 

+ 1 - 3
public/app/features/dashboard/export/export_modal.ts

@@ -17,9 +17,7 @@ export class DashExportCtrl {
   constructor(private backendSrv, dashboardSrv, datasourceSrv, $scope) {
     this.exporter = new DashboardExporter(datasourceSrv);
 
-    var current = dashboardSrv.getCurrent().getSaveModelClone();
-
-    this.exporter.makeExportable(current).then(dash => {
+    this.exporter.makeExportable(dashboardSrv.getCurrent()).then(dash => {
       $scope.$apply(() => {
         this.dash = dash;
       });

+ 29 - 8
public/app/features/dashboard/export/exporter.ts

@@ -11,19 +11,40 @@ export class DashboardExporter {
   constructor(private datasourceSrv) {
   }
 
-  makeExportable(dash) {
+  makeExportable(dashboard) {
     var dynSrv = new DynamicDashboardSrv();
-    dynSrv.init(dash, {variables: dash.templating.list});
+
+    // clean up repeated rows and panels,
+    // this is done on the live real dashboard instance, not on a clone
+    // so we need to undo this
+    // this is pretty hacky and needs to be changed
+    dynSrv.init(dashboard);
     dynSrv.process({cleanUpOnly: true});
 
-    dash.id = null;
+    var saveModel = dashboard.getSaveModelClone();
+    saveModel.id = null;
+
+    // undo repeat cleanup
+    dynSrv.process();
 
     var inputs = [];
     var requires = {};
     var datasources = {};
     var promises = [];
+    var variableLookup: any = {};
+
+    for (let variable of saveModel.templating.list) {
+      variableLookup[variable.name] = variable;
+    }
 
     var templateizeDatasourceUsage = obj => {
+      // ignore data source properties that contain a variable
+      if (obj.datasource && obj.datasource.indexOf('$') === 0) {
+        if (variableLookup[obj.datasource.substring(1)]){
+          return;
+        }
+      }
+
       promises.push(this.datasourceSrv.get(obj.datasource).then(ds => {
         if (ds.meta.builtIn) {
           return;
@@ -50,7 +71,7 @@ export class DashboardExporter {
     };
 
     // check up panel data sources
-    for (let row of dash.rows) {
+    for (let row of saveModel.rows) {
       for (let panel of row.panels) {
         if (panel.datasource !== undefined) {
           templateizeDatasourceUsage(panel);
@@ -77,7 +98,7 @@ export class DashboardExporter {
     }
 
     // templatize template vars
-    for (let variable of dash.templating.list) {
+    for (let variable of saveModel.templating.list) {
       if (variable.type === 'query') {
         templateizeDatasourceUsage(variable);
         variable.options = [];
@@ -87,7 +108,7 @@ export class DashboardExporter {
     }
 
     // templatize annotations vars
-    for (let annotationDef of dash.annotations.list) {
+    for (let annotationDef of saveModel.annotations.list) {
       templateizeDatasourceUsage(annotationDef);
     }
 
@@ -105,7 +126,7 @@ export class DashboardExporter {
       });
 
       // templatize constants
-      for (let variable of dash.templating.list) {
+      for (let variable of saveModel.templating.list) {
         if (variable.type === 'constant') {
           var refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase();
           inputs.push({
@@ -133,7 +154,7 @@ export class DashboardExporter {
       newObj["__inputs"] = inputs;
       newObj["__requires"] = requires;
 
-      _.defaults(newObj, dash);
+      _.defaults(newObj, saveModel);
 
       return newObj;
     }).catch(err => {

+ 8 - 7
public/app/features/dashboard/model.ts

@@ -98,12 +98,14 @@ export class DashboardModel {
     var events = this.events;
     var meta = this.meta;
     var rows = this.rows;
+    var variables = this.templating.list;
+
     delete this.events;
     delete this.meta;
 
     // prepare save model
-    this.rows = _.map(this.rows, row => row.getSaveModel());
-    events.emit('prepare-save-model');
+    this.rows = _.map(rows, row => row.getSaveModel());
+    this.templating.list = _.map(variables, variable => variable.getSaveModel ? variable.getSaveModel() : variable);
 
     var copy = $.extend(true, {}, this);
 
@@ -111,6 +113,8 @@ export class DashboardModel {
     this.events = events;
     this.meta = meta;
     this.rows = rows;
+    this.templating.list = variables;
+
     return copy;
   }
 
@@ -233,7 +237,6 @@ export class DashboardModel {
   }
 
   duplicatePanel(panel, row) {
-    var rowIndex = _.indexOf(this.rows, row);
     var newPanel = angular.copy(panel);
     newPanel.id = this.getNextPanelId();
 
@@ -241,9 +244,9 @@ export class DashboardModel {
     delete newPanel.repeatIteration;
     delete newPanel.repeatPanelId;
     delete newPanel.scopedVars;
+    delete newPanel.alert;
 
-    var currentRow = this.rows[rowIndex];
-    currentRow.panels.push(newPanel);
+    row.addPanel(newPanel);
     return newPanel;
   }
 
@@ -490,8 +493,6 @@ export class DashboardModel {
             templateVariable.hide = 2;
           } else if (templateVariable.hideLabel) {
             templateVariable.hide = 1;
-          } else {
-            templateVariable.hide = 0;
           }
         });
       }

+ 0 - 282
public/app/features/dashboard/partials/globalAlerts.html

@@ -1,282 +0,0 @@
-<topnav title="Alerting" subnav="false">
-  <ul class="nav">
-    <li class="active" ><a href="global-alerts">Global Alerts</a></li>
-  </ul>
-</topnav>
-
-<div class="page-container">
-  <div class="page-wide">
-    <h1>Global alerts</h1>
-
-    <div class="filter-controls-filters">
-      <div class="tight-form last">
-        <ul class="tight-form-list">
-          <li class="tight-form-item">Filters:</li>
-          <li class="tight-form-item">Alert State</li>
-          <li><!-- <value-select-dropdown></value-select-dropdown> --></li>
-          <li class="tight-form-item">Dashboards</li>
-          <li><!-- <value-select-dropdown></value-select-dropdown> --></li>
-          <li class="tight-form-item">
-            <a class="pointer">
-              <i class="fa fa-pencil"></i>
-            </a>
-          </li>
-        </ul>
-        <div class="clearfix"></div>
-      </div>
-    </div>
-    <ul class="filter-controls-actions">
-      <li>
-        <div class="dropdown">
-          <button class="btn btn-inverse dropdown-toggle" data-toggle="dropdown">
-            <input class="cr1" id="state-enabled" type="checkbox">
-            <label for="state-enabled" class="cr1"></label> <span class="caret"></span>
-          </button>
-          <ul class="dropdown-menu" role="menu">
-            <li><a>All</a></li>
-          </ul>
-        </div>
-      </li>
-      <li>
-        <div class="dropdown">
-          <button class="btn btn-inverse dropdown-toggle" data-toggle="dropdown">
-            Bulk Actions &nbsp; <span class="caret"></span>
-          </button>
-          <ul class="dropdown-menu" role="menu">
-            <li><a>Update notifications</a></li>
-          </ul>
-        </div>
-      </li>
-      <li>
-        <button class="btn btn-inverse" data-toggle="dropdown">
-          <i class="fa fa-fw fa-th-large"></i> New Dashboard from selected
-        </button>
-      </li>
-      <li>
-        <span class="filter-controls-actions-selected">2 selected, showing 6 of 6 total</span>
-      </li>
-    </ul>
-    <ul class="filter-list">
-      <li>
-        <ul class="filter-list-card">
-          <li class="filter-list-card-select">
-            <input class="cr1" id="alert1" type="checkbox">
-            <label for="alert1" class="cr1"></label>
-          </li>
-          <li>
-            <div class="filter-list-card-controls">
-              <div class="filter-list-card-links">
-                <span class="filter-list-card-link"><i class="fa fa-fw fa-th-large"></i>: <a href="">OpSec Super Sekret</a></span>
-                <span class="filter-list-card-link">Panel: <a href="">Prod CPU Data Writes</a></span>
-              </div>
-              <div class="filter-list-card-config">
-                <a href="#"><i class="fa fa-cog"></i></a>
-              </div>
-              <div class="filter-list-card-expand" ng-click="alert1.expanded = !alert1.expanded">
-                <i class="fa fa-angle-right" ng-show="!alert1.expanded"></i>
-                <i class="fa fa-angle-down" ng-show="alert1.expanded"></i>
-              </div>
-            </div>
-            <span class="filter-list-card-title">Prod CPU Data Writes</span>
-            <span class="filter-list-card-status">
-              <span class="filter-list-card-state online">Online</span> for 19 hours
-            </span>
-          </li>
-        </ul>
-        <div class="filter-list-card-details" ng-show="alert1.expanded">
-          <h5 class="filter-list-card-details-heading">Alert query <a>configure alerting</a></h5>
-          <div class="tight-form last">
-            <ul class="tight-form-list">
-              <li class="tight-form-item" style="min-width: 15px; text-align: center">A</li>
-              <li class="tight-form-item">apps</li>
-              <li class="tight-form-item"><i class="fa fa-asterisk"><i></i></i></li>
-              <li class="tight-form-item">fakesite</li>
-              <li class="tight-form-item">counters</li>
-              <li class="tight-form-item">requests</li>
-              <li class="tight-form-item">count</li>
-              <li class="tight-form-item">scaleToSeconds(1)</li>
-              <li class="tight-form-item">aliasByNode(2)</li>
-            </ul>
-            <div class="clearfix"></div>
-          </div>
-        </div>
-      </li>
-      <li>
-        <ul class="filter-list-card">
-          <li class="filter-list-card-select">
-            <input class="cr1" id="alert2" type="checkbox" checked>
-            <label for="alert2" class="cr1"></label>
-          </li>
-          <li>
-            <div class="filter-list-card-controls">
-              <div class="filter-list-card-links">
-                <span class="filter-list-card-link"><i class="fa fa-fw fa-th-large"></i>: <a href="">OpSec Insanely Super Duper Sekret</a></span>
-                <span class="filter-list-card-link">Panel: <a href="">client side full page load</a></span>
-              </div>
-              <div class="filter-list-card-config">
-                <a href="#"><i class="fa fa-cog"></i></a>
-              </div>
-              <div class="filter-list-card-expand" ng-click="alert2.expanded = !alert2.expanded">
-                <i class="fa fa-angle-right" ng-show="!alert2.expanded"></i>
-                <i class="fa fa-angle-down" ng-show="alert2.expanded"></i>
-              </div>
-            </div>
-            <span class="filter-list-card-title">Prod DB Reads</span>
-            <span class="filter-list-card-status">
-              <span class="filter-list-card-state warn">Warn</span> for 1 hour
-            </span>
-          </li>
-        </ul>
-        <div class="filter-list-card-details" ng-show="alert2.expanded">
-          <h5 class="filter-list-card-details-heading">Alert query <a>configure alerting</a></h5>
-          <div class="tight-form last">
-            <ul class="tight-form-list">
-              <li class="tight-form-item" style="min-width: 15px; text-align: center">A</li>
-              <li class="tight-form-item">apps</li>
-              <li class="tight-form-item"><i class="fa fa-asterisk"><i></i></i></li>
-              <li class="tight-form-item">fakesite</li>
-              <li class="tight-form-item">counters</li>
-              <li class="tight-form-item">requests</li>
-              <li class="tight-form-item">count</li>
-              <li class="tight-form-item">scaleToSeconds(1)</li>
-              <li class="tight-form-item">aliasByNode(2)</li>
-            </ul>
-            <div class="clearfix"></div>
-          </div>
-        </div>
-      </li>
-      <li>
-        <ul class="filter-list-card">
-          <li class="filter-list-card-select">
-            <input class="cr1" id="alert3" type="checkbox" checked>
-            <label for="alert3" class="cr1"></label>
-          </li>
-          <li>
-            <div class="filter-list-card-controls">
-              <div class="filter-list-card-links">
-                <span class="filter-list-card-link"><i class="fa fa-fw fa-th-large"></i>: <a href="">OpSec Mildly Sekret</a></span>
-                <span class="filter-list-card-link">Panel: <a href="">Memory/CPU</a></span>
-              </div>
-              <div class="filter-list-card-config">
-                <a href="#"><i class="fa fa-cog"></i></a>
-              </div>
-              <div class="filter-list-card-expand" ng-click="alert3.expanded = !alert3.expanded">
-                <i class="fa fa-angle-right" ng-show="!alert3.expanded"></i>
-                <i class="fa fa-angle-down" ng-show="alert3.expanded"></i>
-              </div>
-            </div>
-            <span class="filter-list-card-title">Prod CPU Data Writes</span>
-            <span class="filter-list-card-status">
-              <span class="filter-list-card-state critical">Online</span> for 10 minutes
-            </span>
-          </li>
-        </ul>
-        <div class="filter-list-card-details" ng-show="alert3.expanded">
-          <h5 class="filter-list-card-details-heading">Alert query <a>configure alerting</a></h5>
-          <div class="tight-form last">
-            <ul class="tight-form-list">
-              <li class="tight-form-item" style="min-width: 15px; text-align: center">A</li>
-              <li class="tight-form-item">apps</li>
-              <li class="tight-form-item"><i class="fa fa-asterisk"><i></i></i></li>
-              <li class="tight-form-item">fakesite</li>
-              <li class="tight-form-item">counters</li>
-              <li class="tight-form-item">requests</li>
-              <li class="tight-form-item">count</li>
-              <li class="tight-form-item">scaleToSeconds(1)</li>
-              <li class="tight-form-item">aliasByNode(2)</li>
-            </ul>
-            <div class="clearfix"></div>
-          </div>
-        </div>
-      </li>
-      <li>
-        <ul class="filter-list-card">
-          <li class="filter-list-card-select">
-            <input class="cr1" id="alert4" type="checkbox">
-            <label for="alert4" class="cr1"></label>
-          </li>
-          <li>
-            <div class="filter-list-card-controls">
-              <div class="filter-list-card-links">
-                <span class="filter-list-card-link"><i class="fa fa-fw fa-th-large"></i>: <a href="">OpSec Super Sekret</a></span>
-                <span class="filter-list-card-link">Panel: <a href="">Stacked lines</a></span>
-              </div>
-              <div class="filter-list-card-config">
-                <a href="#"><i class="fa fa-cog"></i></a>
-              </div>
-              <div class="filter-list-card-expand" ng-click="alert4.expanded = !alert4.expanded">
-                <i class="fa fa-angle-right" ng-show="!alert4.expanded"></i>
-                <i class="fa fa-angle-down" ng-show="alert4.expanded"></i>
-              </div>
-            </div>
-            <span class="filter-list-card-title">Critical Thing</span>
-            <span class="filter-list-card-status">
-              <span class="filter-list-card-state online">Online</span> for 5 weeks
-            </span>
-          </li>
-        </ul>
-        <div class="filter-list-card-details" ng-show="alert4.expanded">
-          <h5 class="filter-list-card-details-heading">Alert query <a>configure alerting</a></h5>
-          <div class="tight-form last">
-            <ul class="tight-form-list">
-              <li class="tight-form-item" style="min-width: 15px; text-align: center">A</li>
-              <li class="tight-form-item">apps</li>
-              <li class="tight-form-item"><i class="fa fa-asterisk"><i></i></i></li>
-              <li class="tight-form-item">fakesite</li>
-              <li class="tight-form-item">counters</li>
-              <li class="tight-form-item">requests</li>
-              <li class="tight-form-item">count</li>
-              <li class="tight-form-item">scaleToSeconds(1)</li>
-              <li class="tight-form-item">aliasByNode(2)</li>
-            </ul>
-            <div class="clearfix"></div>
-          </div>
-        </div>
-      </li>
-      <li>
-        <ul class="filter-list-card">
-          <li class="filter-list-card-select">
-            <input class="cr1" id="alert5" type="checkbox">
-            <label for="alert5" class="cr1"></label>
-          </li>
-          <li>
-            <div class="filter-list-card-controls">
-              <div class="filter-list-card-links">
-                <span class="filter-list-card-link"><i class="fa fa-fw fa-th-large"></i>: <a href="">OpSec Public</a></span>
-                <span class="filter-list-card-link">Panel: <a href="">More Critical Thing</a></span>
-              </div>
-              <div class="filter-list-card-config">
-                <a href="#"><i class="fa fa-cog"></i></a>
-              </div>
-              <div class="filter-list-card-expand" ng-click="alert5.expanded = !alert5.expanded">
-                <i class="fa fa-angle-right" ng-show="!alert5.expanded"></i>
-                <i class="fa fa-angle-down" ng-show="alert5.expanded"></i>
-              </div>
-            </div>
-            <span class="filter-list-card-title">More Critical Thing</span>
-            <span class="filter-list-card-status">
-              <span class="filter-list-card-state online">Online</span> for 2 months
-            </span>
-          </li>
-        </ul>
-        <div class="filter-list-card-details" ng-show="alert5.expanded">
-          <h5 class="filter-list-card-details-heading">Alert query <a>configure alerting</a></h5>
-          <div class="tight-form last">
-            <ul class="tight-form-list">
-              <li class="tight-form-item" style="min-width: 15px; text-align: center">A</li>
-              <li class="tight-form-item">apps</li>
-              <li class="tight-form-item"><i class="fa fa-asterisk"><i></i></i></li>
-              <li class="tight-form-item">fakesite</li>
-              <li class="tight-form-item">counters</li>
-              <li class="tight-form-item">requests</li>
-              <li class="tight-form-item">count</li>
-              <li class="tight-form-item">scaleToSeconds(1)</li>
-              <li class="tight-form-item">aliasByNode(2)</li>
-            </ul>
-            <div class="clearfix"></div>
-          </div>
-        </div>
-      </li>
-    </ul>
-  </div>
-</div>

+ 1 - 1
public/app/features/dashboard/row/add_panel.html

@@ -5,7 +5,7 @@
 
   <div class="gf-form-inline dash-row-add-panel-form">
     <div class="gf-form">
-      <input type="text" class="gf-form-input max-width-14" ng-model='ctrl.panelSearch' give-focus='true' ng-keydown="ctrl.keyDown($event)" ng-change="ctrl.panelSearchChanged()" placeholder="panel search filter" ng-blur="ctrl.panelSearchBlur()"></input>
+      <input type="text" class="gf-form-input max-width-14" ng-model='ctrl.panelSearch' give-focus='true' ng-keydown="ctrl.keyDown($event)" ng-change="ctrl.panelSearchChanged()" placeholder="panel search filter"></input>
     </div>
   </div>
 

+ 0 - 6
public/app/features/dashboard/row/add_panel.ts

@@ -45,12 +45,6 @@ export class AddPanelCtrl {
     }
   }
 
-  panelSearchBlur() {
-    // this.$timeout(() => {
-    //   this.rowCtrl.dropView = 0;
-    // }, 400);
-  }
-
   moveSelection(direction) {
     var max = this.panelHits.length;
     var newIndex = this.activeIndex + direction;

+ 2 - 3
public/app/features/dashboard/row/row_ctrl.ts

@@ -19,7 +19,6 @@ export class DashRowCtrl {
 
     if (this.row.isNew) {
       this.dropView = 1;
-      delete this.row.isNew;
     }
   }
 
@@ -35,8 +34,8 @@ export class DashRowCtrl {
           title: config.new_panel_title,
           type: panelId,
           id: this.dashboard.getNextPanelId(),
+          isNew: true,
         },
-        isNew: true,
       };
     } else {
       dragObject = this.dashboard.getPanelInfoById(panelId);
@@ -65,7 +64,7 @@ export class DashRowCtrl {
       this.row.panels.push(dragObject.panel);
 
       // if not new remove from source row
-      if (!dragObject.isNew) {
+      if (!dragObject.panel.isNew) {
         dragObject.row.removePanel(dragObject.panel, false);
       }
     }

+ 4 - 0
public/app/features/dashboard/row/row_model.ts

@@ -33,7 +33,11 @@ export class DashboardRow {
   }
 
   getSaveModel() {
+    this.model = {};
     assignModelProperties(this.model, this, this.defaults);
+
+    // remove properties that dont server persisted purpose
+    delete this.model.isNew;
     return this.model;
   }
 

+ 6 - 2
public/app/features/dashboard/specs/dashboard_srv_specs.ts

@@ -62,7 +62,9 @@ describe('dashboardSrv', function() {
 
     it('duplicate panel should try to add it to same row', function() {
       var panel = { span: 4, attr: '123', id: 10 };
-      dashboard.rows = [{ panels: [panel] }];
+
+      dashboard.addEmptyRow();
+      dashboard.rows[0].addPanel(panel);
       dashboard.duplicatePanel(panel, dashboard.rows[0]);
 
       expect(dashboard.rows[0].panels[0].span).to.be(4);
@@ -73,7 +75,9 @@ describe('dashboardSrv', function() {
 
     it('duplicate panel should remove repeat data', function() {
       var panel = { span: 4, attr: '123', id: 10, repeat: 'asd', scopedVars: { test: 'asd' }};
-      dashboard.rows = [{ panels: [panel] }];
+
+      dashboard.addEmptyRow();
+      dashboard.rows[0].addPanel(panel);
       dashboard.duplicatePanel(panel, dashboard.rows[0]);
 
       expect(dashboard.rows[0].panels[1].repeat).to.be(undefined);

+ 1 - 3
public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts

@@ -20,7 +20,6 @@ function dynamicDashScenario(desc, func)  {
 
       beforeEach(angularMocks.inject(function(dashboardSrv) {
         ctx.dashboardSrv = dashboardSrv;
-        ctx.variableSrv = {};
 
         var model = {
           rows: [],
@@ -29,9 +28,8 @@ function dynamicDashScenario(desc, func)  {
 
         setupFunc(model);
         ctx.dash = ctx.dashboardSrv.create(model);
-        ctx.variableSrv.variables = ctx.dash.templating.list;
         ctx.dynamicDashboardSrv = new DynamicDashboardSrv();
-        ctx.dynamicDashboardSrv.init(ctx.dash, ctx.variableSrv);
+        ctx.dynamicDashboardSrv.init(ctx.dash);
         ctx.dynamicDashboardSrv.process();
         ctx.rows = ctx.dash.rows;
       }));

+ 10 - 1
public/app/features/dashboard/specs/exporter_specs.ts

@@ -34,6 +34,14 @@ describe('given dashboard with repeated panels', function() {
       options: []
     });
 
+    dash.templating.list.push({
+      name: 'ds',
+      type: 'datasource',
+      query: 'testdb',
+      current: {value: 'prod', text: 'prod'},
+      options: []
+    });
+
     dash.annotations.list.push({
       name: 'logs',
       datasource: 'gfdb',
@@ -49,6 +57,7 @@ describe('given dashboard with repeated panels', function() {
           datasource: '-- Mixed --',
           targets: [{datasource: 'other'}],
         },
+        {id: 5, datasource: '$ds'},
       ]
     });
 
@@ -87,7 +96,7 @@ describe('given dashboard with repeated panels', function() {
   });
 
   it('exported dashboard should not contain repeated panels', function() {
-    expect(exported.rows[0].panels.length).to.be(2);
+    expect(exported.rows[0].panels.length).to.be(3);
   });
 
   it('exported dashboard should not contain repeated rows', function() {

+ 1 - 0
public/app/features/dashboard/submenu/submenu.ts

@@ -15,6 +15,7 @@ export class SubmenuCtrl {
               private $location) {
     this.annotations = this.dashboard.templating.list;
     this.variables = this.variableSrv.variables;
+    console.log(this.variables);
   }
 
   annotationStateChanged() {

+ 8 - 1
public/app/features/dashboard/unsavedChangesSrv.js

@@ -143,7 +143,14 @@ function(angular, _) {
       };
 
       modalScope.save = function() {
-        tracker.scope.$emit('save-dashboard');
+        var cancel = $rootScope.$on('dashboard-saved', function() {
+          cancel();
+          $timeout(function() {
+            tracker.goto_next();
+          });
+        });
+
+        $rootScope.$emit('save-dashboard');
       };
 
       $rootScope.appEvent('show-modal', {

+ 1 - 0
public/app/features/panel/metrics_panel_ctrl.ts

@@ -252,6 +252,7 @@ class MetricsPanelCtrl extends PanelCtrl {
       },
       complete: () => {
         console.log('panel: observer got complete');
+        this.dataStream = null;
       }
     });
   }

+ 9 - 0
public/app/features/panel/panel_ctrl.ts

@@ -54,6 +54,12 @@ export class PanelCtrl {
       this.events.emit('panel-teardown');
       this.events.removeAllListeners();
     });
+
+    // we should do something interesting
+    // with newly added panels
+    if (this.panel.isNew) {
+      delete this.panel.isNew;
+    }
   }
 
   init() {
@@ -188,6 +194,9 @@ export class PanelCtrl {
 
   duplicate() {
     this.dashboard.duplicatePanel(this.panel, this.row);
+    this.$timeout(() => {
+      this.$scope.$root.$broadcast('render');
+    });
   }
 
   updateColumnSpan(span) {

+ 8 - 2
public/app/features/panel/panel_directive.ts

@@ -68,8 +68,8 @@ module.directive('grafanaPanel', function($rootScope) {
 
       // the reason for handling these classes this way is for performance
       // limit the watchers on panels etc
-      var transparentLastState;
-      var lastHasAlertRule;
+      var transparentLastState = false;
+      var lastHasAlertRule = false;
       var lastAlertState;
       var hasAlertRule;
       var lastHeight = 0;
@@ -91,6 +91,12 @@ module.directive('grafanaPanel', function($rootScope) {
         lastHeight = ctrl.containerHeight;
       }
 
+      // set initial transparency
+      if (ctrl.panel.transparent) {
+        transparentLastState = true;
+        panelContainer.addClass('panel-transparent', true);
+      }
+
       ctrl.events.on('render', () => {
         if (lastHeight !== ctrl.containerHeight) {
           panelContainer.css({minHeight: ctrl.containerHeight});

+ 0 - 56
public/app/features/panel/partials/query_editor_row.html

@@ -57,59 +57,3 @@
 	</div>
 </div>
 
-<div class="tight-form" ng-if="false">
-	<ul class="tight-form-list pull-right">
-		<li ng-show="ctrl.error" class="tight-form-item">
-			<a bs-tooltip="ctrl.error" style="color: rgb(229, 189, 28)" role="menuitem">
-				<i class="fa fa-warning"></i>
-			</a>
-		</li>
-		<li class="tight-form-item small" ng-show="ctrl.target.datasource">
-			<em>{{ctrl.target.datasource}}</em>
-		</li>
-		<li class="tight-form-item" ng-if="ctrl.toggleEditorMode">
-			<a class="pointer" tabindex="1" ng-click="ctrl.toggleEditorMode()">
-				<i class="fa fa-pencil"></i>
-			</a>
-		</li>
-		<li class="tight-form-item">
-			<div class="dropdown">
-				<a class="pointer dropdown-toggle" data-toggle="dropdown" tabindex="1">
-					<i class="fa fa-bars"></i>
-				</a>
-				<ul class="dropdown-menu pull-right" role="menu">
-					<li role="menuitem">
-						<a tabindex="1" ng-click="ctrl.duplicateQuery()">Duplicate</a>
-					</li>
-					<li role="menuitem">
-						<a tabindex="1" ng-click="ctrl.moveQuery(-1)">Move up</a>
-					</li>
-					<li role="menuitem">
-						<a tabindex="1" ng-click="ctrl.moveQuery(1)">Move down</a>
-					</li>
-				</ul>
-			</div>
-		</li>
-		<li class="tight-form-item last">
-			<a class="pointer" tabindex="1" ng-click="ctrl.removeQuery(target)">
-				<i class="fa fa-trash"></i>
-			</a>
-		</li>
-	</ul>
-
-	<ul class="tight-form-list">
-		<li class="tight-form-item" style="min-width: 15px; text-align: center">
-			{{ctrl.target.refId}}
-		</li>
-		<li>
-			<a class="tight-form-item" ng-click="ctrl.toggleHideQuery()" role="menuitem">
-				<i class="fa fa-eye"></i>
-			</a>
-		</li>
-	</ul>
-
-	<ul class="tight-form-list" ng-transclude>
-	</ul>
-
-	<div class="clearfix"></div>
-</div>

+ 101 - 40
public/app/features/plugins/partials/ds_http_settings.html

@@ -1,56 +1,69 @@
 
 
 <div class="gf-form-group">
-	<h3 class="page-heading">Http settings</h3>
+  <h3 class="page-heading">Http settings</h3>
+  <div class="gf-form-group">
+    <div class="gf-form-inline">
+      <div class="gf-form max-width-30">
+        <span class="gf-form-label width-7">Url</span>
+        <input class="gf-form-input" type="text"
+              ng-model='current.url' placeholder="{{suggestUrl}}"
+              bs-typeahead="getSuggestUrls"  min-length="0"
+              ng-pattern="/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/" required></input>
+        <info-popover mode="right-absolute">
+          <p>Specify a complete HTTP url (for example http://your_server:8080)</p>
+          <span ng-show="current.access === 'direct'">
+            Your access method is <em>Direct</em>, this means the url
+            needs to be accessable from the browser.
+          </span>
+          <span ng-show="current.access === 'proxy'">
+            Your access method is currently <em>Proxy</em>, this means the url
+            needs to be accessable from the grafana backend.
+          </span>
+        </info-popover>
+      </div>
+    </div>
 
-	<div class="gf-form-inline">
-		<div class="gf-form max-width-30">
-			<span class="gf-form-label width-7">Url</span>
-			<input class="gf-form-input" type="text"
-						 ng-model='current.url' placeholder="{{suggestUrl}}"
-						 bs-typeahead="getSuggestUrls"  min-length="0"
-						 ng-pattern="/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/" required></input>
-			<info-popover mode="right-absolute">
-				<p>Specify a complete HTTP url (for example http://your_server:8080)</p>
-				<span ng-show="current.access === 'direct'">
-					Your access method is <em>Direct</em>, this means the url
-					needs to be accessable from the browser.
-				</span>
-				<span ng-show="current.access === 'proxy'">
-					Your access method is currently <em>Proxy</em>, this means the url
-					needs to be accessable from the grafana backend.
-				</span>
-			</info-popover>
-		</div>
-	</div>
+    <div class="gf-form-inline">
+      <div class="gf-form max-width-30">
+        <span class="gf-form-label width-7">Access</span>
+        <div class="gf-form-select-wrapper gf-form-select-wrapper--has-help-icon max-width-24">
+          <select class="gf-form-input" ng-model="current.access" ng-options="f for f in ['direct', 'proxy']"></select>
+          <info-popover mode="right-absolute">
+            Direct = url is used directly from browser<br>
+            Proxy = Grafana backend will proxy the request
+          </info-popover>
+        </div>
+      </div>
+    </div>
+  </div>
 
-	<div class="gf-form-inline">
-		<div class="gf-form max-width-30">
-			<span class="gf-form-label width-7">Access</span>
-			<div class="gf-form-select-wrapper gf-form-select-wrapper--has-help-icon max-width-24">
-				<select class="gf-form-input" ng-model="current.access" ng-options="f for f in ['direct', 'proxy']"></select>
-				<info-popover mode="right-absolute">
-					Direct = url is used directly from browser<br>
-					Proxy = Grafana backend will proxy the request
-				</info-popover>
-			</div>
-		</div>
-	</div>
+  <h3 class="page-heading">Http Auth</h3>
 
 	<div class="gf-form-inline">
-		<div class="gf-form">
-			<label class="gf-form-label width-7">Http Auth</label>
-		</div>
 		<gf-form-switch class="gf-form"
 									label="Basic Auth"
-				 checked="current.basicAuth" switch-class="max-width-6">
+				 checked="current.basicAuth" label-class="width-8" switch-class="max-width-6">
 		</gf-form-switch>
 		<gf-form-switch class="gf-form"
-									label="With Credentials"
-				 checked="current.withCredentials" switch-class="max-width-6">
+									label="With Credentials" tooltip="Whether credentials such as cookies or auth headers should be sent with cross-site requests."
+				 checked="current.withCredentials" label-class="width-11" switch-class="max-width-6">
 		</gf-form-switch>
 	</div>
+  <div class="gf-form-inline">
+    <gf-form-switch class="gf-form" ng-if="current.access=='proxy'"
+									label="TLS Client Auth" label-class="width-8"
+				 checked="current.jsonData.tlsAuth" switch-class="max-width-6">
+		</gf-form-switch>
+    <gf-form-switch class="gf-form" ng-if="current.access=='proxy'"
+									label="With CA Cert" tooltip="Optional. Needed for self-signed TLS Certs."
+				 checked="current.jsonData.tlsAuthWithCACert" label-class="width-11" switch-class="max-width-6">
+		</gf-form-switch>
+  </div>
+</div>
 
+<div class="gf-form-group" ng-if="current.basicAuth">
+  <h6>Basic Auth Details</h6>
 	<div class="gf-form" ng-if="current.basicAuth">
 		<span class="gf-form-label width-7">
 			User
@@ -58,7 +71,7 @@
 		<input class="gf-form-input max-width-21" type="text"  ng-model='current.basicAuthUser' placeholder="user" required></input>
 	</div>
 
-	<div class="gf-form" ng-if="current.basicAuth">
+	<div class="gf-form">
 		<span class="gf-form-label width-7">
 			Password
 		</span>
@@ -66,3 +79,51 @@
 	</div>
 </div>
 
+<div class="gf-form-group" ng-if="current.jsonData.tlsAuth && current.access=='proxy'">
+  <div class="gf-form">
+    <h6>TLS Auth Details</h6>
+    <info-popover mode="header">TLS Certs are encrypted and stored in the Grafana database.</info-popover>
+  </div>
+  <div ng-if="current.jsonData.tlsAuthWithCACert">
+    <div class="gf-form-inline">
+      <div class="gf-form gf-form--v-stretch">
+        <label class="gf-form-label width-7">CA Cert</label>
+      </div>
+      <div class="gf-form gf-form--grow" ng-if="!current.tlsAuth.tlsCACertSet">
+        <textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsCACert" placeholder="Begins with -----BEGIN CERTIFICATE-----. The CA Certificate is necessary if you are using self-signed certificates."></textarea>
+      </div>
+
+      <div class="gf-form" ng-if="current.tlsAuth.tlsCACertSet">
+        <input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
+        <a class="btn btn-secondary gf-form-btn" href="#" ng-if="current.tlsAuth.tlsCACertSet" ng-click="current.tlsAuth.tlsCACertSet = false">reset</a>
+      </div>
+    </div>
+  </div>
+
+  <div class="gf-form-inline">
+    <div class="gf-form gf-form--v-stretch">
+      <label class="gf-form-label width-7">Client Cert</label>
+    </div>
+    <div class="gf-form gf-form--grow" ng-if="!current.tlsAuth.tlsClientCertSet">
+      <textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientCert" placeholder="Begins with -----BEGIN CERTIFICATE-----" required></textarea>
+    </div>
+    <div class="gf-form" ng-if="current.tlsAuth.tlsClientCertSet">
+      <input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
+      <a class="btn btn-secondary gf-form-btn" href="#" ng-if="current.tlsAuth.tlsClientCertSet" ng-click="current.tlsAuth.tlsClientCertSet = false">reset</a>
+    </div>
+  </div>
+
+  <div class="gf-form-inline">
+    <div class="gf-form gf-form--v-stretch">
+      <label class="gf-form-label width-7">Client Key</label>
+    </div>
+    <div class="gf-form gf-form--grow" ng-if="!current.tlsAuth.tlsClientKeySet">
+      <textarea rows="7" class="gf-form-input gf-form-textarea" ng-model="current.secureJsonData.tlsClientKey" placeholder="Begins with -----BEGIN RSA PRIVATE KEY-----" required></textarea>
+    </div>
+    <div class="gf-form" ng-if="current.tlsAuth.tlsClientKeySet">
+      <input type="text" class="gf-form-input max-width-12" disabled="disabled" value="configured">
+      <a class="btn btn-secondary gf-form-btn" href="#" ng-if="current.tlsAuth.tlsClientKeySet" ng-click="current.tlsAuth.tlsClientKeySet = false">reset</a>
+    </div>
+  </div>
+</div>
+

+ 1 - 1
public/app/features/templating/adhoc_variable.ts

@@ -26,7 +26,7 @@ export class AdhocVariable implements Variable {
     return Promise.resolve();
   }
 
-  getModel() {
+  getSaveModel() {
     assignModelProperties(this.model, this, this.defaults);
     return this.model;
   }

+ 1 - 1
public/app/features/templating/constant_variable.ts

@@ -24,7 +24,7 @@ export class ConstantVariable implements Variable {
     assignModelProperties(this, model, this.defaults);
   }
 
-  getModel() {
+  getSaveModel() {
     assignModelProperties(this.model, this, this.defaults);
     return this.model;
   }

+ 1 - 1
public/app/features/templating/custom_variable.ts

@@ -34,7 +34,7 @@ export class CustomVariable implements Variable {
     return this.variableSrv.setOptionAsCurrent(this, option);
   }
 
-  getModel() {
+  getSaveModel() {
     assignModelProperties(this.model, this, this.defaults);
     return this.model;
   }

+ 4 - 1
public/app/features/templating/datasource_variable.ts

@@ -30,8 +30,11 @@ export class DatasourceVariable implements Variable {
     this.refresh = 1;
   }
 
-  getModel() {
+  getSaveModel() {
     assignModelProperties(this.model, this, this.defaults);
+
+    // dont persist options
+    this.model.options = [];
     return this.model;
   }
 

+ 1 - 1
public/app/features/templating/editor_ctrl.ts

@@ -106,7 +106,7 @@ export class VariableEditorCtrl {
     };
 
     $scope.duplicate = function(variable) {
-      var clone = _.cloneDeep(variable.getModel());
+      var clone = _.cloneDeep(variable.getSaveModel());
       $scope.current = variableSrv.createVariableFromModel(clone);
       $scope.variables.push($scope.current);
       $scope.current.name = 'copy_of_'+variable.name;

+ 1 - 1
public/app/features/templating/interval_variable.ts

@@ -34,7 +34,7 @@ export class IntervalVariable implements Variable {
     this.refresh = 2;
   }
 
-  getModel() {
+  getSaveModel() {
     assignModelProperties(this.model, this, this.defaults);
     return this.model;
   }

+ 1 - 1
public/app/features/templating/partials/editor.html

@@ -136,7 +136,7 @@
 			<div ng-if="current.type === 'custom'" class="gf-form-group">
         <h5 class="section-heading">Custom Options</h5>
 				<div class="gf-form">
-					<span class="gf-form-label width-13">Values separated by comma</span>
+					<span class="gf-form-label width-14">Values separated by comma</span>
 					<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue" required></input>
 				</div>
 			</div>

+ 7 - 1
public/app/features/templating/query_variable.ts

@@ -47,9 +47,15 @@ export class QueryVariable implements Variable {
     assignModelProperties(this, model, this.defaults);
   }
 
-  getModel() {
+  getSaveModel() {
     // copy back model properties to model
     assignModelProperties(this.model, this, this.defaults);
+
+    // remove options
+    if (this.refresh !== 0) {
+      this.model.options = [];
+    }
+
     return this.model;
   }
 

Неке датотеке нису приказане због велике количине промена