Bladeren bron

Merge branch 'alert_ui_take2' into alerting

bergquist 9 jaren geleden
bovenliggende
commit
d52fc8d0ad
67 gewijzigde bestanden met toevoegingen van 1968 en 1387 verwijderingen
  1. 2 3
      .floo
  2. 1 0
      CHANGELOG.md
  3. 167 0
      alerting_model.json
  4. 1 8
      pkg/api/alerting.go
  5. 33 23
      pkg/api/common.go
  6. 5 7
      pkg/api/dashboard.go
  7. 8 0
      pkg/log/log.go
  8. 5 1
      pkg/middleware/logger.go
  9. 5 7
      pkg/middleware/middleware.go
  10. 0 2
      pkg/middleware/middleware_test.go
  11. 2 5
      pkg/middleware/quota.go
  12. 116 0
      pkg/models/alert.go
  13. 1 1
      pkg/models/alert_state.go
  14. 39 0
      pkg/models/alert_test.go
  15. 0 125
      pkg/models/alerts.go
  16. 80 0
      pkg/services/alerting/alert_rule.go
  17. 38 0
      pkg/services/alerting/commands.go
  18. 0 75
      pkg/services/alerting/dashboard_parser.go
  19. 6 0
      pkg/services/alerting/engine.go
  20. 15 0
      pkg/services/alerting/evaluator.go
  21. 17 85
      pkg/services/alerting/executor.go
  22. 33 8
      pkg/services/alerting/executor_test.go
  23. 119 0
      pkg/services/alerting/extractor.go
  24. 219 0
      pkg/services/alerting/extractor_test.go
  25. 9 16
      pkg/services/alerting/models.go
  26. 1 16
      pkg/services/alerting/reader.go
  27. 6 5
      pkg/services/alerting/scheduler.go
  28. 71 0
      pkg/services/alerting/transformers/aggregation.go
  29. 7 0
      pkg/services/alerting/transformers/transformer.go
  30. 1 0
      pkg/services/search/handlers.go
  31. 1 0
      pkg/services/search/models.go
  32. 25 16
      pkg/services/sqlstore/alert.go
  33. 3 3
      pkg/services/sqlstore/alert_rule_changes.go
  34. 6 15
      pkg/services/sqlstore/alert_rule_changes_test.go
  35. 22 48
      pkg/services/sqlstore/alert_rule_test.go
  36. 3 2
      pkg/services/sqlstore/alert_state.go
  37. 6 15
      pkg/services/sqlstore/alert_state_test.go
  38. 7 1
      pkg/services/sqlstore/dashboard.go
  39. 0 415
      pkg/services/sqlstore/dashboard_parser_test.go
  40. 1 3
      pkg/services/sqlstore/datasource_test.go
  41. 8 14
      pkg/services/sqlstore/migrations/alert_mig.go
  42. 0 2
      pkg/services/sqlstore/migrations/migrations_test.go
  43. 1 1
      pkg/services/sqlstore/migrator/migrator.go
  44. 2 2
      pkg/services/sqlstore/sqlstore.go
  45. 6 7
      pkg/tsdb/graphite/graphite.go
  46. 123 0
      public/app/core/components/query_part/query_part.ts
  47. 183 0
      public/app/core/components/query_part/query_part_editor.ts
  48. 2 0
      public/app/core/core.ts
  49. 13 10
      public/app/features/dashboard/viewStateSrv.js
  50. 1 1
      public/app/features/org/partials/profile.html
  51. 2 2
      public/app/features/panel/panel_ctrl.ts
  52. 4 4
      public/app/plugins/datasource/influxdb/partials/query.editor.html
  53. 0 5
      public/app/plugins/datasource/influxdb/partials/query_part.html
  54. 0 3
      public/app/plugins/datasource/influxdb/query_ctrl.ts
  55. 46 148
      public/app/plugins/datasource/influxdb/query_part.ts
  56. 0 178
      public/app/plugins/datasource/influxdb/query_part_editor.js
  57. 1 4
      public/app/plugins/datasource/opentsdb/datasource.js
  58. 135 0
      public/app/plugins/panel/graph/alert_handle.ts
  59. 135 42
      public/app/plugins/panel/graph/alert_tab_ctrl.ts
  60. 67 1
      public/app/plugins/panel/graph/graph.js
  61. 90 49
      public/app/plugins/panel/graph/partials/tab_alerting.html
  62. 1 1
      public/sass/_variables.dark.scss
  63. 64 0
      public/sass/components/_panel_graph.scss
  64. 1 0
      public/sass/components/_tagsinput.scss
  65. 0 6
      public/sass/pages/_dashboard.scss
  66. 1 1
      public/vendor/flot/jquery.flot.js
  67. 1 1
      public/vendor/tagsinput/bootstrap-tagsinput.js

+ 2 - 3
.floo

@@ -1,4 +1,3 @@
 {
-  "url": "https://floobits.com/raintank/grafana"
-}
-
+    "url": "https://floobits.com/raintank/grafana"
+}

+ 1 - 0
CHANGELOG.md

@@ -10,6 +10,7 @@
 * **InfluxDB**: Add spread function, closes [#5211](https://github.com/grafana/grafana/issues/5211)
 * **Scripts**: Use restart instead of start for deb package script, closes [#5282](https://github.com/grafana/grafana/pull/5282)
 * **Logging**: Moved to structured logging lib, and moved to component specific level filters via config file, closes [#4590](https://github.com/grafana/grafana/issues/4590)
+* **Search**: Add search limit query parameter, closes [#5292](https://github.com/grafana/grafana/pull/5292)
 
 ## Breaking changes
 * **Logging** : Changed default logging output format (now structured into message, and key value pairs, with logger key acting as component). You can also no change in config to json log ouput.

+ 167 - 0
alerting_model.json

@@ -0,0 +1,167 @@
+{
+  "alert": {
+    "name": "Majority servers down",
+    "frequency": 60,
+    "notify": ["group1", "group2"],
+    "expressions": [
+        {
+          "left": [
+            {
+              "type": "query",
+              "refId": "A",
+              "timeRange": {"from": "5m", "to": "now-1m"},
+            },
+            {
+              "type": "function",
+              "name": "max"
+            }
+          ],
+          "operator": ">",
+          "right": [
+            {
+              "type": "constant",
+              "value": 100
+            }
+          ],
+          "level": 2,
+        }
+    ]
+  },
+
+  "alert": {
+    "name": "Majority servers down take2",
+    "frequency": 60,
+    "notify": ["group1", "group2"],
+    "expressions": [
+        {
+          "left": [
+            {
+              "type": "query",
+              "refId": "A",
+              "timeRange": {"from": "5m", "to": "now-1m"},
+            },
+            {
+              "type": "function",
+              "name": "max"
+            }
+          ],
+          "operator": ">",
+          "right": [
+            {
+              "type": "query",
+              "refId": "A",
+              "timeRange": {"from": "now-1d-5m", "to": "now-1d"},
+            },
+            {
+              "type": "function",
+              "name": "max"
+            }
+          ],
+          "level": 2,
+        }
+    ]
+  },
+  "alert": {
+    "name": "CPU usage last 5min above 90%",
+    "frequency": 60,
+    "expressions": [
+      {
+        "expr": "query(#A, 5m, now, avg)",
+        "operator": ">",
+        "critLevel": 90,
+      }
+    ]
+  },
+  "alert": {
+    "name": "Series count above 10",
+    "frequency": "1m",
+    "expressions": [
+      {
+        "expr": "query(#A, 5m, now, avg) | countSeries()",
+        "operator": ">",
+        "critLevel": 10,
+      }
+    ]
+  },
+  "alert": {
+    "name": "Disk Free Zero in 3 days",
+    "frequency": "1d",
+    "expressions": [
+      {
+        "expr": "query(#A, 1d, now, trend(3d))",
+        "operator": ">",
+        "critLevel": 0,
+      }
+    ]
+  },
+  "alert": {
+    "name": "Server requests is zero for more than 10min",
+    "frequency": "1d",
+    "expressions": [
+      {
+        "expr": "query(#A, 10m, now, sum)",
+        "operator": "=",
+        "critLevel": 0,
+      }
+    ]
+  },
+  "alert": {
+    "name": "Timeouts should not be more than 0.1% of requests",
+    "frequency": "1d",
+    "expressions": [
+      {
+        "expr": "query(#A, 10m, now, sum) | subtract | query(#B, 10m, now, sum)",
+        "operator": ">",
+        "critLevel": 0,
+      }
+    ]
+  },
+  "alert": {
+    "name": "CPU usage last 5min changed by more than 20% compared to last 24hours",
+    "frequency": "1m",
+    "value": "query(#A, 5m, now, avg)",
+    "operator": "percent change",
+    "threshold": "query(#A, 1d, now, avg)",
+  },
+
+  "alert": {
+    "name": "CPU higher than 90%",
+    "frequency": "1m",
+    "valueExpr": "query(#A, 5m, now, avg)",
+    "evalType": "greater than",
+    "critLevel": 20,
+    "warnLevel": 10,
+  },
+
+  "alert": {
+    "name": "CPU usage last 5min changed by more than 20% compared to last 24hours",
+    "frequency": "1m",
+    "expr": "query(#A, 5m, now, avg) percentGreaterThan()",
+    "evalType": "percentscre change",
+    "evalExpr": "query(#A, 1d, now, avg)",
+    "critLevel": 20,
+    "warnLevel": 10,
+  },
+  "alert": {
+    "name": "CPU usage last 5min changed by more than 20% compared to last 24hours",
+    "frequency": "1m",
+    "valueQuery": "query(#A, 5m, now, avg) ",
+    "evalType": "simple", "// other options are: percent change, trend"
+    "evalQuery": "query(#A, 1d, now, avg)",
+    "comparison": "greater than",
+    "critLevel": 20,
+    "warnLevel": 10,
+  },
+  "alert": {
+    "name": "CPU usage last 5min changed by more than 20% compared to last 24hours",
+    "frequency": "1m",
+    "valueQuery": "query(#A, 5m, now, avg) | Evaluate Against: Static Threshold | >200 Warn | >300 Critical",
+    "valueQuery": "query(#A, 5m, now, avg) | Evaluate Against: Percent Change Compared To | query(#B, 5m, now, avg) | >200 Warn | >300 Critical",
+    "valueQuery": "query(#A, 5m, now, trend) | Evaluate Against: Forcast | 7days | >200 Warn | >300 Critical",
+    "evalType": "simple", "// other options are: percent change, trend"
+    "evalQuery": "query(#A, 1d, now, avg)",
+    "comparison": "greater than",
+    "critLevel": 20,
+    "warnLevel": 10,
+  },
+}

+ 1 - 8
pkg/api/alerting.go

@@ -43,7 +43,7 @@ func GetAlertChanges(c *middleware.Context) Response {
 	return Json(200, query.Result)
 }
 
-// GET /api/alerts
+// GET /api/alerts/rules/
 func GetAlerts(c *middleware.Context) Response {
 	query := models.GetAlertsQuery{
 		OrgId:       c.OrgId,
@@ -64,15 +64,8 @@ func GetAlerts(c *middleware.Context) Response {
 			Id:          alert.Id,
 			DashboardId: alert.DashboardId,
 			PanelId:     alert.PanelId,
-			Query:       alert.Query,
-			QueryRefId:  alert.QueryRefId,
-			WarnLevel:   alert.WarnLevel,
-			CritLevel:   alert.CritLevel,
-			Frequency:   alert.Frequency,
 			Name:        alert.Name,
 			Description: alert.Description,
-			QueryRange:  alert.QueryRange,
-			Aggregator:  alert.Aggregator,
 			State:       alert.State,
 		})
 	}

+ 33 - 23
pkg/api/common.go

@@ -4,7 +4,6 @@ import (
 	"encoding/json"
 	"net/http"
 
-	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/setting"
@@ -21,13 +20,15 @@ var (
 )
 
 type Response interface {
-	WriteTo(out http.ResponseWriter)
+	WriteTo(ctx *middleware.Context)
 }
 
 type NormalResponse struct {
-	status int
-	body   []byte
-	header http.Header
+	status     int
+	body       []byte
+	header     http.Header
+	errMessage string
+	err        error
 }
 
 func wrap(action interface{}) macaron.Handler {
@@ -41,17 +42,21 @@ func wrap(action interface{}) macaron.Handler {
 			res = ServerError(err)
 		}
 
-		res.WriteTo(c.Resp)
+		res.WriteTo(c)
 	}
 }
 
-func (r *NormalResponse) WriteTo(out http.ResponseWriter) {
-	header := out.Header()
+func (r *NormalResponse) WriteTo(ctx *middleware.Context) {
+	if r.err != nil {
+		ctx.Logger.Error(r.errMessage, "error", r.err)
+	}
+
+	header := ctx.Resp.Header()
 	for k, v := range r.header {
 		header[k] = v
 	}
-	out.WriteHeader(r.status)
-	out.Write(r.body)
+	ctx.Resp.WriteHeader(r.status)
+	ctx.Resp.Write(r.body)
 }
 
 func (r *NormalResponse) Cache(ttl string) *NormalResponse {
@@ -64,7 +69,6 @@ func (r *NormalResponse) Header(key, value string) *NormalResponse {
 }
 
 // functions to create responses
-
 func Empty(status int) *NormalResponse {
 	return Respond(status, nil)
 }
@@ -80,29 +84,35 @@ func ApiSuccess(message string) *NormalResponse {
 }
 
 func ApiError(status int, message string, err error) *NormalResponse {
-	resp := make(map[string]interface{})
-
-	if err != nil {
-		log.Error(4, "%s: %v", message, err)
-		if setting.Env != setting.PROD {
-			resp["error"] = err.Error()
-		}
-	}
+	data := make(map[string]interface{})
 
 	switch status {
 	case 404:
 		metrics.M_Api_Status_404.Inc(1)
-		resp["message"] = "Not Found"
+		data["message"] = "Not Found"
 	case 500:
 		metrics.M_Api_Status_500.Inc(1)
-		resp["message"] = "Internal Server Error"
+		data["message"] = "Internal Server Error"
 	}
 
 	if message != "" {
-		resp["message"] = message
+		data["message"] = message
+	}
+
+	if err != nil {
+		if setting.Env != setting.PROD {
+			data["error"] = err.Error()
+		}
+	}
+
+	resp := Json(status, data)
+
+	if err != nil {
+		resp.errMessage = message
+		resp.err = err
 	}
 
-	return Json(status, resp)
+	return resp
 }
 
 func Respond(status int, body interface{}) *NormalResponse {

+ 5 - 7
pkg/api/dashboard.go

@@ -151,15 +151,13 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) {
 	}
 
 	if setting.AlertingEnabled {
-		saveAlertCommand := m.SaveAlertsCommand{
-			DashboardId: cmd.Result.Id,
-			OrgId:       c.OrgId,
-			UserId:      c.UserId,
-			Alerts:      alerting.ParseAlertsFromDashboard(&cmd),
+		alertCmd := alerting.UpdateDashboardAlertsCommand{
+			OrgId:     c.OrgId,
+			UserId:    c.UserId,
+			Dashboard: cmd.Result,
 		}
 
-		err = bus.Dispatch(&saveAlertCommand)
-		if err != nil {
+		if err := bus.Dispatch(&alertCmd); err != nil {
 			c.JsonApiErr(500, "Failed to save alerts", err)
 			return
 		}

+ 8 - 0
pkg/log/log.go

@@ -12,6 +12,7 @@ import (
 
 	"gopkg.in/ini.v1"
 
+	"github.com/go-stack/stack"
 	"github.com/inconshreveable/log15"
 	"github.com/inconshreveable/log15/term"
 )
@@ -22,6 +23,7 @@ var loggersToClose []DisposableHandler
 func init() {
 	loggersToClose = make([]DisposableHandler, 0)
 	Root = log15.Root()
+	Root.SetHandler(log15.DiscardHandler())
 }
 
 func New(logger string, ctx ...interface{}) Logger {
@@ -227,3 +229,9 @@ func LogFilterHandler(maxLevel log15.Lvl, filters map[string]log15.Lvl, h log15.
 		return r.Lvl <= maxLevel
 	}, h)
 }
+
+func Stack(skip int) string {
+	call := stack.Caller(skip)
+	s := stack.Trace().TrimBelow(call).TrimRuntime()
+	return s.String()
+}

+ 5 - 1
pkg/middleware/logger.go

@@ -48,7 +48,11 @@ func Logger() macaron.Handler {
 
 		if ctx, ok := c.Data["ctx"]; ok {
 			ctxTyped := ctx.(*Context)
-			ctxTyped.Logger.Info("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ns", timeTakenMs, "size", rw.Size())
+			if status == 500 {
+				ctxTyped.Logger.Error("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ns", timeTakenMs, "size", rw.Size())
+			} else {
+				ctxTyped.Logger.Info("Request Completed", "method", req.Method, "path", req.URL.Path, "status", status, "remote_addr", c.RemoteAddr(), "time_ns", timeTakenMs, "size", rw.Size())
+			}
 		}
 	}
 }

+ 5 - 7
pkg/middleware/middleware.go

@@ -80,7 +80,7 @@ func initContextWithAnonymousUser(ctx *Context) bool {
 func initContextWithUserSessionCookie(ctx *Context) bool {
 	// initialize session
 	if err := ctx.Session.Start(ctx); err != nil {
-		log.Error(3, "Failed to start session", err)
+		ctx.Logger.Error("Failed to start session", "error", err)
 		return false
 	}
 
@@ -91,7 +91,7 @@ func initContextWithUserSessionCookie(ctx *Context) bool {
 
 	query := m.GetSignedInUserQuery{UserId: userId}
 	if err := bus.Dispatch(&query); err != nil {
-		log.Error(3, "Failed to get user with id %v", userId)
+		ctx.Logger.Error("Failed to get user with id", "userId", userId)
 		return false
 	} else {
 		ctx.SignedInUser = query.Result
@@ -185,7 +185,7 @@ func initContextWithApiKeyFromSession(ctx *Context) bool {
 
 	keyQuery := m.GetApiKeyByIdQuery{ApiKeyId: keyId.(int64)}
 	if err := bus.Dispatch(&keyQuery); err != nil {
-		log.Error(3, "Failed to get api key by id", err)
+		ctx.Logger.Error("Failed to get api key by id", "id", keyId, "error", err)
 		return false
 	} else {
 		apikey := keyQuery.Result
@@ -202,7 +202,7 @@ func initContextWithApiKeyFromSession(ctx *Context) bool {
 // Handle handles and logs error by given status.
 func (ctx *Context) Handle(status int, title string, err error) {
 	if err != nil {
-		log.Error(4, "%s: %v", title, err)
+		ctx.Logger.Error(title, "error", err)
 		if setting.Env != setting.PROD {
 			ctx.Data["ErrorMsg"] = err
 		}
@@ -223,9 +223,7 @@ func (ctx *Context) Handle(status int, title string, err error) {
 
 func (ctx *Context) JsonOK(message string) {
 	resp := make(map[string]interface{})
-
 	resp["message"] = message
-
 	ctx.JSON(200, resp)
 }
 
@@ -237,7 +235,7 @@ func (ctx *Context) JsonApiErr(status int, message string, err error) {
 	resp := make(map[string]interface{})
 
 	if err != nil {
-		log.Error(4, "%s: %v", message, err)
+		ctx.Logger.Error(message, "error", err)
 		if setting.Env != setting.PROD {
 			resp["error"] = err.Error()
 		}

+ 0 - 2
pkg/middleware/middleware_test.go

@@ -191,9 +191,7 @@ func TestMiddlewareContext(t *testing.T) {
 				}
 			})
 
-			var createUserCmd *m.CreateUserCommand
 			bus.AddHandler("test", func(cmd *m.CreateUserCommand) error {
-				createUserCmd = cmd
 				cmd.Result = m.User{Id: 33}
 				return nil
 			})

+ 2 - 5
pkg/middleware/quota.go

@@ -4,7 +4,6 @@ import (
 	"fmt"
 
 	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/log"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 	"gopkg.in/macaron.v1"
@@ -35,10 +34,8 @@ func QuotaReached(c *Context, target string) (bool, error) {
 		return false, err
 	}
 
-	log.Debug(fmt.Sprintf("checking quota for %s in scopes %v", target, scopes))
-
 	for _, scope := range scopes {
-		log.Debug(fmt.Sprintf("checking scope %s", scope.Name))
+		c.Logger.Debug("Checking quota", "target", target, "scope", scope)
 
 		switch scope.Name {
 		case "global":
@@ -51,7 +48,7 @@ func QuotaReached(c *Context, target string) (bool, error) {
 			if target == "session" {
 				usedSessions := getSessionCount()
 				if int64(usedSessions) > scope.DefaultLimit {
-					log.Debug(fmt.Sprintf("%d sessions active, limit is %d", usedSessions, scope.DefaultLimit))
+					c.Logger.Debug("Sessions limit reached", "active", usedSessions, "limit", scope.DefaultLimit)
 					return true, nil
 				}
 				continue

+ 116 - 0
pkg/models/alert.go

@@ -0,0 +1,116 @@
+package models
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+)
+
+type Alert struct {
+	Id          int64
+	OrgId       int64
+	DashboardId int64
+	PanelId     int64
+	Name        string
+	Description string
+	State       string
+	Scheduler   int64
+	Enabled     bool
+	Frequency   int
+
+	Created time.Time
+	Updated time.Time
+
+	Expression *simplejson.Json
+}
+
+func (alert *Alert) ValidToSave() bool {
+	return alert.DashboardId != 0
+}
+
+func (this *Alert) ContainsUpdates(other *Alert) bool {
+	result := false
+	result = result || this.Name != other.Name
+	result = result || this.Description != other.Description
+
+	if this.Expression != nil && other.Expression != nil {
+		json1, err1 := this.Expression.Encode()
+		json2, err2 := other.Expression.Encode()
+
+		if err1 != nil || err2 != nil {
+			return false
+		}
+
+		result = result || string(json1) != string(json2)
+	}
+
+	//don't compare .State! That would be insane.
+	return result
+}
+
+type AlertingClusterInfo struct {
+	ServerId       string
+	ClusterSize    int
+	UptimePosition int
+}
+
+type HeartBeat struct {
+	Id       int64
+	ServerId string
+	Updated  time.Time
+	Created  time.Time
+}
+
+type HeartBeatCommand struct {
+	ServerId string
+	Result   AlertingClusterInfo
+}
+
+type AlertChange struct {
+	Id      int64     `json:"id"`
+	OrgId   int64     `json:"-"`
+	AlertId int64     `json:"alertId"`
+	Type    string    `json:"type"`
+	Created time.Time `json:"created"`
+}
+
+// Commands
+type SaveAlertsCommand struct {
+	DashboardId int64
+	UserId      int64
+	OrgId       int64
+
+	Alerts []*Alert
+}
+
+type DeleteAlertCommand struct {
+	AlertId int64
+}
+
+//Queries
+type GetAlertsQuery struct {
+	OrgId       int64
+	State       []string
+	DashboardId int64
+	PanelId     int64
+
+	Result []*Alert
+}
+
+type GetAllAlertsQuery struct {
+	Result []*Alert
+}
+
+type GetAlertByIdQuery struct {
+	Id int64
+
+	Result *Alert
+}
+
+type GetAlertChangesQuery struct {
+	OrgId   int64
+	Limit   int64
+	SinceId int64
+
+	Result []*AlertChange
+}

+ 1 - 1
pkg/models/alerts_state.go → pkg/models/alert_state.go

@@ -31,7 +31,7 @@ type UpdateAlertStateCommand struct {
 	NewState string `json:"newState" binding:"Required"`
 	Info     string `json:"info"`
 
-	Result *AlertRule
+	Result *Alert
 }
 
 // Queries

+ 39 - 0
pkg/models/alert_test.go

@@ -0,0 +1,39 @@
+package models
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestAlertingModelTest(t *testing.T) {
+	Convey("Testing Alerting model", t, func() {
+
+		json1, _ := simplejson.NewJson([]byte(`{ "field": "value" }`))
+		json2, _ := simplejson.NewJson([]byte(`{ "field": "value" }`))
+
+		rule1 := &Alert{
+			Expression:  json1,
+			Name:        "Namn",
+			Description: "Description",
+		}
+
+		rule2 := &Alert{
+			Expression:  json2,
+			Name:        "Namn",
+			Description: "Description",
+		}
+
+		Convey("Testing AlertRule equals", func() {
+
+			So(rule1.ContainsUpdates(rule2), ShouldBeFalse)
+		})
+
+		Convey("Changing the expression should contain update", func() {
+			json2, _ := simplejson.NewJson([]byte(`{ "field": "newValue" }`))
+			rule1.Expression = json2
+			So(rule1.ContainsUpdates(rule2), ShouldBeTrue)
+		})
+	})
+}

+ 0 - 125
pkg/models/alerts.go

@@ -1,125 +0,0 @@
-package models
-
-import (
-	"time"
-)
-
-type AlertRule struct {
-	Id           int64   `json:"id"`
-	OrgId        int64   `json:"-"`
-	DatasourceId int64   `json:"datasourceId"`
-	DashboardId  int64   `json:"dashboardId"`
-	PanelId      int64   `json:"panelId"`
-	Query        string  `json:"query"`
-	QueryRefId   string  `json:"queryRefId"`
-	WarnLevel    float64 `json:"warnLevel"`
-	CritLevel    float64 `json:"critLevel"`
-	WarnOperator string  `json:"warnOperator"`
-	CritOperator string  `json:"critOperator"`
-	Frequency    int64   `json:"frequency"`
-	Name         string  `json:"name"`
-	Description  string  `json:"description"`
-	QueryRange   int     `json:"queryRange"`
-	Aggregator   string  `json:"aggregator"`
-	State        string  `json:"state"`
-
-	Created time.Time `json:"created"`
-	Updated time.Time `json:"updated"`
-}
-
-func (alertRule *AlertRule) ValidToSave() bool {
-	return alertRule.Query != "" && alertRule.Frequency != 0 && alertRule.QueryRange != 0 && alertRule.Name != ""
-}
-
-func (this *AlertRule) Equals(other *AlertRule) bool {
-	result := false
-
-	result = result || this.Aggregator != other.Aggregator
-	result = result || this.CritLevel != other.CritLevel
-	result = result || this.WarnLevel != other.WarnLevel
-	result = result || this.WarnOperator != other.WarnOperator
-	result = result || this.CritOperator != other.CritOperator
-	result = result || this.Query != other.Query
-	result = result || this.QueryRefId != other.QueryRefId
-	result = result || this.Frequency != other.Frequency
-	result = result || this.Name != other.Name
-	result = result || this.Description != other.Description
-	result = result || this.QueryRange != other.QueryRange
-	//don't compare .State! That would be insane.
-
-	return result
-}
-
-type AlertingClusterInfo struct {
-	ServerId       string
-	ClusterSize    int
-	UptimePosition int
-}
-
-type HeartBeat struct {
-	Id       int64
-	ServerId string
-	Updated  time.Time
-	Created  time.Time
-}
-
-type HeartBeatCommand struct {
-	ServerId string
-
-	Result AlertingClusterInfo
-}
-
-type AlertRuleChange struct {
-	Id      int64     `json:"id"`
-	OrgId   int64     `json:"-"`
-	AlertId int64     `json:"alertId"`
-	Type    string    `json:"type"`
-	Created time.Time `json:"created"`
-}
-
-// Commands
-type SaveAlertsCommand struct {
-	DashboardId int64
-	UserId      int64
-	OrgId       int64
-
-	Alerts []*AlertRule
-}
-
-type DeleteAlertCommand struct {
-	AlertId int64
-}
-
-//Queries
-type GetAlertsQuery struct {
-	OrgId       int64
-	State       []string
-	DashboardId int64
-	PanelId     int64
-
-	Result []*AlertRule
-}
-
-type GetAllAlertsQuery struct {
-	Result []*AlertRule
-}
-
-type GetAlertsForExecutionQuery struct {
-	Timestamp int64
-
-	Result []*AlertRule
-}
-
-type GetAlertByIdQuery struct {
-	Id int64
-
-	Result *AlertRule
-}
-
-type GetAlertChangesQuery struct {
-	OrgId   int64
-	Limit   int64
-	SinceId int64
-
-	Result []*AlertRuleChange
-}

+ 80 - 0
pkg/services/alerting/alert_rule.go

@@ -0,0 +1,80 @@
+package alerting
+
+import (
+	"fmt"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/services/alerting/transformers"
+
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+type AlertRule struct {
+	Id              int64
+	OrgId           int64
+	DashboardId     int64
+	PanelId         int64
+	Frequency       int64
+	Name            string
+	Description     string
+	State           string
+	Warning         Level
+	Critical        Level
+	Query           AlertQuery
+	Transform       string
+	TransformParams simplejson.Json
+	Transformer     transformer.Transformer
+}
+
+func getTimeDurationStringToSeconds(str string) int64 {
+	return 60
+}
+
+func NewAlertRuleFromDBModel(ruleDef *m.Alert) (*AlertRule, error) {
+	model := &AlertRule{}
+	model.Id = ruleDef.Id
+	model.OrgId = ruleDef.OrgId
+	model.Name = ruleDef.Name
+	model.Description = ruleDef.Description
+	model.State = ruleDef.State
+
+	critical := ruleDef.Expression.Get("critical")
+	model.Critical = Level{
+		Operator: critical.Get("op").MustString(),
+		Level:    critical.Get("level").MustFloat64(),
+	}
+
+	warning := ruleDef.Expression.Get("warn")
+	model.Warning = Level{
+		Operator: warning.Get("op").MustString(),
+		Level:    warning.Get("level").MustFloat64(),
+	}
+
+	model.Frequency = getTimeDurationStringToSeconds(ruleDef.Expression.Get("frequency").MustString())
+	model.Transform = ruleDef.Expression.Get("transform").Get("type").MustString()
+	model.TransformParams = *ruleDef.Expression.Get("transform")
+
+	if model.Transform == "aggregation" {
+		method := ruleDef.Expression.Get("transform").Get("method").MustString()
+		model.Transformer = transformer.NewAggregationTransformer(method)
+	}
+
+	query := ruleDef.Expression.Get("query")
+	model.Query = AlertQuery{
+		Query:        query.Get("query").MustString(),
+		DatasourceId: query.Get("datasourceId").MustInt64(),
+		From:         query.Get("from").MustString(),
+		To:           query.Get("to").MustString(),
+		Aggregator:   query.Get("agg").MustString(),
+	}
+
+	if model.Query.Query == "" {
+		return nil, fmt.Errorf("missing query.query")
+	}
+
+	if model.Query.DatasourceId == 0 {
+		return nil, fmt.Errorf("missing query.datasourceId")
+	}
+
+	return model, nil
+}

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

@@ -0,0 +1,38 @@
+package alerting
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+type UpdateDashboardAlertsCommand struct {
+	UserId    int64
+	OrgId     int64
+	Dashboard *m.Dashboard
+}
+
+func init() {
+	bus.AddHandler("alerting", updateDashboardAlerts)
+}
+
+func updateDashboardAlerts(cmd *UpdateDashboardAlertsCommand) error {
+	saveAlerts := m.SaveAlertsCommand{
+		OrgId:       cmd.OrgId,
+		UserId:      cmd.UserId,
+		DashboardId: cmd.Dashboard.Id,
+	}
+
+	extractor := NewDashAlertExtractor(cmd.Dashboard, cmd.OrgId)
+
+	if alerts, err := extractor.GetAlerts(); err != nil {
+		return err
+	} else {
+		saveAlerts.Alerts = alerts
+	}
+
+	if err := bus.Dispatch(&saveAlerts); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 0 - 75
pkg/services/alerting/dashboard_parser.go

@@ -1,75 +0,0 @@
-package alerting
-
-import (
-	"github.com/grafana/grafana/pkg/bus"
-	"github.com/grafana/grafana/pkg/components/simplejson"
-	"github.com/grafana/grafana/pkg/log"
-	m "github.com/grafana/grafana/pkg/models"
-)
-
-func ParseAlertsFromDashboard(cmd *m.SaveDashboardCommand) []*m.AlertRule {
-	alerts := make([]*m.AlertRule, 0)
-
-	for _, rowObj := range cmd.Dashboard.Get("rows").MustArray() {
-		row := simplejson.NewFromAny(rowObj)
-
-		for _, panelObj := range row.Get("panels").MustArray() {
-			panel := simplejson.NewFromAny(panelObj)
-
-			alerting := panel.Get("alerting")
-			alert := &m.AlertRule{
-				DashboardId:  cmd.Result.Id,
-				OrgId:        cmd.Result.OrgId,
-				PanelId:      panel.Get("id").MustInt64(),
-				Id:           alerting.Get("id").MustInt64(),
-				QueryRefId:   alerting.Get("queryRef").MustString(),
-				WarnLevel:    alerting.Get("warnLevel").MustFloat64(),
-				CritLevel:    alerting.Get("critLevel").MustFloat64(),
-				WarnOperator: alerting.Get("warnOperator").MustString(),
-				CritOperator: alerting.Get("critOperator").MustString(),
-				Frequency:    alerting.Get("frequency").MustInt64(),
-				Name:         alerting.Get("name").MustString(),
-				Description:  alerting.Get("description").MustString(),
-				QueryRange:   alerting.Get("queryRange").MustInt(),
-				Aggregator:   alerting.Get("aggregator").MustString(),
-			}
-
-			log.Info("Alertrule: %v", alert.Name)
-			for _, targetsObj := range panel.Get("targets").MustArray() {
-				target := simplejson.NewFromAny(targetsObj)
-
-				if target.Get("refId").MustString() == alert.QueryRefId {
-					targetJson, err := target.MarshalJSON()
-					if err == nil {
-						alert.Query = string(targetJson)
-					}
-					continue
-				}
-			}
-
-			if panel.Get("datasource").MustString() == "" {
-				query := &m.GetDataSourcesQuery{OrgId: cmd.OrgId}
-				if err := bus.Dispatch(query); err == nil {
-					for _, ds := range query.Result {
-						if ds.IsDefault {
-							alert.DatasourceId = ds.Id
-						}
-					}
-				}
-			} else {
-				query := &m.GetDataSourceByNameQuery{
-					Name:  panel.Get("datasource").MustString(),
-					OrgId: cmd.OrgId,
-				}
-				bus.Dispatch(query)
-				alert.DatasourceId = query.Result.Id
-			}
-
-			if alert.ValidToSave() {
-				alerts = append(alerts, alert)
-			}
-		}
-	}
-
-	return alerts
-}

+ 6 - 0
pkg/services/alerting/engine.go

@@ -50,6 +50,12 @@ func (e *Engine) Stop() {
 }
 
 func (e *Engine) alertingTicker() {
+	defer func() {
+		if err := recover(); err != nil {
+			e.log.Error("Scheduler Panic, stopping...", "error", err, "stack", log.Stack(1))
+		}
+	}()
+
 	tickIndex := 0
 
 	for {

+ 15 - 0
pkg/services/alerting/evaluator.go

@@ -0,0 +1,15 @@
+package alerting
+
+type compareFn func(float64, float64) bool
+
+func evalCondition(level Level, result float64) bool {
+	return operators[level.Operator](result, level.Level)
+}
+
+var operators = map[string]compareFn{
+	">":  func(num1, num2 float64) bool { return num1 > num2 },
+	">=": func(num1, num2 float64) bool { return num1 >= num2 },
+	"<":  func(num1, num2 float64) bool { return num1 < num2 },
+	"<=": func(num1, num2 float64) bool { return num1 <= num2 },
+	"":   func(num1, num2 float64) bool { return false },
+}

+ 17 - 85
pkg/services/alerting/executor.go

@@ -2,9 +2,6 @@ package alerting
 
 import (
 	"fmt"
-	"strconv"
-
-	"math"
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/log"
@@ -14,7 +11,6 @@ import (
 )
 
 var (
-	resultLogFmt   = "Alerting: executor %s  %1.2f %s %1.2f : %v"
 	descriptionFmt = "Actual value: %1.2f for %s"
 )
 
@@ -28,63 +24,6 @@ func NewExecutor() *ExecutorImpl {
 	}
 }
 
-type compareFn func(float64, float64) bool
-type aggregationFn func(*tsdb.TimeSeries) float64
-
-var operators = map[string]compareFn{
-	">":  func(num1, num2 float64) bool { return num1 > num2 },
-	">=": func(num1, num2 float64) bool { return num1 >= num2 },
-	"<":  func(num1, num2 float64) bool { return num1 < num2 },
-	"<=": func(num1, num2 float64) bool { return num1 <= num2 },
-	"":   func(num1, num2 float64) bool { return false },
-}
-var aggregator = map[string]aggregationFn{
-	"avg": func(series *tsdb.TimeSeries) float64 {
-		sum := float64(0)
-
-		for _, v := range series.Points {
-			sum += v[0]
-		}
-
-		return sum / float64(len(series.Points))
-	},
-	"sum": func(series *tsdb.TimeSeries) float64 {
-		sum := float64(0)
-
-		for _, v := range series.Points {
-			sum += v[0]
-		}
-
-		return sum
-	},
-	"min": func(series *tsdb.TimeSeries) float64 {
-		min := series.Points[0][0]
-
-		for _, v := range series.Points {
-			if v[0] < min {
-				min = v[0]
-			}
-		}
-
-		return min
-	},
-	"max": func(series *tsdb.TimeSeries) float64 {
-		max := series.Points[0][0]
-
-		for _, v := range series.Points {
-			if v[0] > max {
-				max = v[0]
-			}
-		}
-
-		return max
-	},
-	"mean": func(series *tsdb.TimeSeries) float64 {
-		midPosition := int64(math.Floor(float64(len(series.Points)) / float64(2)))
-		return series.Points[midPosition][0]
-	},
-}
-
 func (e *ExecutorImpl) Execute(job *AlertJob, resultQueue chan *AlertResult) {
 	timeSeries, err := e.executeQuery(job)
 	if err != nil {
@@ -102,12 +41,12 @@ func (e *ExecutorImpl) Execute(job *AlertJob, resultQueue chan *AlertResult) {
 
 func (e *ExecutorImpl) executeQuery(job *AlertJob) (tsdb.TimeSeriesSlice, error) {
 	getDsInfo := &m.GetDataSourceByIdQuery{
-		Id:    job.Rule.DatasourceId,
+		Id:    job.Rule.Query.DatasourceId,
 		OrgId: job.Rule.OrgId,
 	}
 
 	if err := bus.Dispatch(getDsInfo); err != nil {
-		return nil, fmt.Errorf("Could not find datasource for %d", job.Rule.DatasourceId)
+		return nil, fmt.Errorf("Could not find datasource")
 	}
 
 	req := e.GetRequestForAlertRule(job.Rule, getDsInfo.Result)
@@ -130,16 +69,16 @@ func (e *ExecutorImpl) executeQuery(job *AlertJob) (tsdb.TimeSeriesSlice, error)
 }
 
 func (e *ExecutorImpl) GetRequestForAlertRule(rule *AlertRule, datasource *m.DataSource) *tsdb.Request {
-
+	e.log.Debug("GetRequest", "query", rule.Query.Query, "from", rule.Query.From, "datasourceId", datasource.Id)
 	req := &tsdb.Request{
 		TimeRange: tsdb.TimeRange{
-			From: "-" + strconv.Itoa(rule.QueryRange) + "s",
-			To:   "now",
+			From: "-" + rule.Query.From,
+			To:   rule.Query.To,
 		},
 		Queries: []*tsdb.Query{
 			{
-				RefId: rule.QueryRefId,
-				Query: rule.Query,
+				RefId: "A",
+				Query: rule.Query.Query,
 				DataSource: &tsdb.DataSourceInfo{
 					Id:       datasource.Id,
 					Name:     datasource.Name,
@@ -157,33 +96,26 @@ func (e *ExecutorImpl) evaluateRule(rule *AlertRule, series tsdb.TimeSeriesSlice
 	e.log.Debug("Evaluating Alerting Rule", "seriesCount", len(series), "ruleName", rule.Name)
 
 	for _, serie := range series {
-		log.Debug("Evaluating series", "series", serie.Name)
-
-		if aggregator[rule.Aggregator] == nil {
-			continue
-		}
-
-		var aggValue = aggregator[rule.Aggregator](serie)
-		var critOperartor = operators[rule.CritOperator]
-		var critResult = critOperartor(aggValue, rule.CritLevel)
+		e.log.Debug("Evaluating series", "series", serie.Name)
+		transformedValue, _ := rule.Transformer.Transform(serie)
 
-		log.Trace(resultLogFmt, "Crit", serie.Name, aggValue, rule.CritOperator, rule.CritLevel, critResult)
+		critResult := evalCondition(rule.Critical, transformedValue)
+		e.log.Debug("Alert execution Crit", "name", serie.Name, "transformedValue", transformedValue, "operator", rule.Critical.Operator, "level", rule.Critical.Level, "result", critResult)
 		if critResult {
 			return &AlertResult{
 				State:       alertstates.Critical,
-				ActualValue: aggValue,
-				Description: fmt.Sprintf(descriptionFmt, aggValue, serie.Name),
+				ActualValue: transformedValue,
+				Description: fmt.Sprintf(descriptionFmt, transformedValue, serie.Name),
 			}
 		}
 
-		var warnOperartor = operators[rule.CritOperator]
-		var warnResult = warnOperartor(aggValue, rule.CritLevel)
-		log.Trace(resultLogFmt, "Warn", serie.Name, aggValue, rule.WarnOperator, rule.WarnLevel, warnResult)
+		warnResult := evalCondition(rule.Warning, transformedValue)
+		e.log.Debug("Alert execution Warn", "name", serie.Name, "transformedValue", transformedValue, "operator", rule.Warning.Operator, "level", rule.Warning.Level, "result", warnResult)
 		if warnResult {
 			return &AlertResult{
 				State:       alertstates.Warn,
-				Description: fmt.Sprintf(descriptionFmt, aggValue, serie.Name),
-				ActualValue: aggValue,
+				Description: fmt.Sprintf(descriptionFmt, transformedValue, serie.Name),
+				ActualValue: transformedValue,
 			}
 		}
 	}

+ 33 - 8
pkg/services/alerting/executor_test.go

@@ -14,7 +14,10 @@ func TestAlertingExecutor(t *testing.T) {
 
 		Convey("single time serie", func() {
 			Convey("Show return ok since avg is above 2", func() {
-				rule := &AlertRule{CritLevel: 10, CritOperator: ">", Aggregator: "sum"}
+				rule := &AlertRule{
+					Critical:    Level{Level: 10, Operator: ">"},
+					Transformer: &AggregationTransformer{Method: "avg"},
+				}
 
 				timeSeries := []*tsdb.TimeSeries{
 					tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
@@ -25,7 +28,10 @@ func TestAlertingExecutor(t *testing.T) {
 			})
 
 			Convey("Show return critical since below 2", func() {
-				rule := &AlertRule{CritLevel: 10, CritOperator: "<", Aggregator: "sum"}
+				rule := &AlertRule{
+					Critical:    Level{Level: 10, Operator: "<"},
+					Transformer: &AggregationTransformer{Method: "avg"},
+				}
 
 				timeSeries := []*tsdb.TimeSeries{
 					tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
@@ -36,7 +42,10 @@ func TestAlertingExecutor(t *testing.T) {
 			})
 
 			Convey("Show return critical since sum is above 10", func() {
-				rule := &AlertRule{CritLevel: 10, CritOperator: ">", Aggregator: "sum"}
+				rule := &AlertRule{
+					Critical:    Level{Level: 10, Operator: ">"},
+					Transformer: &AggregationTransformer{Method: "sum"},
+				}
 
 				timeSeries := []*tsdb.TimeSeries{
 					tsdb.NewTimeSeries("test1", [][2]float64{{9, 0}, {9, 0}}),
@@ -47,7 +56,10 @@ func TestAlertingExecutor(t *testing.T) {
 			})
 
 			Convey("Show return ok since avg is below 10", func() {
-				rule := &AlertRule{CritLevel: 10, CritOperator: ">", Aggregator: "avg"}
+				rule := &AlertRule{
+					Critical:    Level{Level: 10, Operator: ">"},
+					Transformer: &AggregationTransformer{Method: "avg"},
+				}
 
 				timeSeries := []*tsdb.TimeSeries{
 					tsdb.NewTimeSeries("test1", [][2]float64{{9, 0}, {9, 0}}),
@@ -58,7 +70,10 @@ func TestAlertingExecutor(t *testing.T) {
 			})
 
 			Convey("Show return ok since min is below 10", func() {
-				rule := &AlertRule{CritLevel: 10, CritOperator: ">", Aggregator: "min"}
+				rule := &AlertRule{
+					Critical:    Level{Level: 10, Operator: ">"},
+					Transformer: &AggregationTransformer{Method: "avg"},
+				}
 
 				timeSeries := []*tsdb.TimeSeries{
 					tsdb.NewTimeSeries("test1", [][2]float64{{11, 0}, {9, 0}}),
@@ -69,7 +84,10 @@ func TestAlertingExecutor(t *testing.T) {
 			})
 
 			Convey("Show return ok since max is above 10", func() {
-				rule := &AlertRule{CritLevel: 10, CritOperator: ">", Aggregator: "max"}
+				rule := &AlertRule{
+					Critical:    Level{Level: 10, Operator: ">"},
+					Transformer: &AggregationTransformer{Method: "max"},
+				}
 
 				timeSeries := []*tsdb.TimeSeries{
 					tsdb.NewTimeSeries("test1", [][2]float64{{1, 0}, {11, 0}}),
@@ -78,11 +96,15 @@ func TestAlertingExecutor(t *testing.T) {
 				result := executor.evaluateRule(rule, timeSeries)
 				So(result.State, ShouldEqual, alertstates.Critical)
 			})
+
 		})
 
 		Convey("muliple time series", func() {
 			Convey("both are ok", func() {
-				rule := &AlertRule{CritLevel: 10, CritOperator: ">", Aggregator: "sum"}
+				rule := &AlertRule{
+					Critical:    Level{Level: 10, Operator: ">"},
+					Transformer: &AggregationTransformer{Method: "avg"},
+				}
 
 				timeSeries := []*tsdb.TimeSeries{
 					tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
@@ -94,7 +116,10 @@ func TestAlertingExecutor(t *testing.T) {
 			})
 
 			Convey("first serie is good, second is critical", func() {
-				rule := &AlertRule{CritLevel: 10, CritOperator: ">", Aggregator: "sum"}
+				rule := &AlertRule{
+					Critical:    Level{Level: 10, Operator: ">"},
+					Transformer: &AggregationTransformer{Method: "avg"},
+				}
 
 				timeSeries := []*tsdb.TimeSeries{
 					tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),

+ 119 - 0
pkg/services/alerting/extractor.go

@@ -0,0 +1,119 @@
+package alerting
+
+import (
+	"errors"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/log"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+type DashAlertExtractor struct {
+	Dash  *m.Dashboard
+	OrgId int64
+	log   log.Logger
+}
+
+func NewDashAlertExtractor(dash *m.Dashboard, orgId int64) *DashAlertExtractor {
+	return &DashAlertExtractor{
+		Dash:  dash,
+		OrgId: orgId,
+		log:   log.New("alerting.extractor"),
+	}
+}
+
+func (e *DashAlertExtractor) lookupDatasourceId(dsName string) (int64, error) {
+	if dsName == "" {
+		query := &m.GetDataSourcesQuery{OrgId: e.OrgId}
+		if err := bus.Dispatch(query); err != nil {
+			return 0, err
+		} else {
+			for _, ds := range query.Result {
+				if ds.IsDefault {
+					return ds.Id, nil
+				}
+			}
+		}
+	} else {
+		query := &m.GetDataSourceByNameQuery{Name: dsName, OrgId: e.OrgId}
+		if err := bus.Dispatch(query); err != nil {
+			return 0, err
+		} else {
+			return query.Result.Id, nil
+		}
+	}
+
+	return 0, errors.New("Could not find datasource id for " + dsName)
+}
+
+func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
+	e.log.Debug("GetAlerts")
+
+	alerts := make([]*m.Alert, 0)
+
+	for _, rowObj := range e.Dash.Data.Get("rows").MustArray() {
+		row := simplejson.NewFromAny(rowObj)
+
+		for _, panelObj := range row.Get("panels").MustArray() {
+			panel := simplejson.NewFromAny(panelObj)
+			jsonAlert, hasAlert := panel.CheckGet("alert")
+
+			if !hasAlert {
+				continue
+			}
+
+			alert := &m.Alert{
+				DashboardId: e.Dash.Id,
+				OrgId:       e.OrgId,
+				PanelId:     panel.Get("id").MustInt64(),
+				Id:          jsonAlert.Get("id").MustInt64(),
+				Name:        jsonAlert.Get("name").MustString(),
+				Scheduler:   jsonAlert.Get("scheduler").MustInt64(),
+				Enabled:     jsonAlert.Get("enabled").MustBool(),
+				Description: jsonAlert.Get("description").MustString(),
+			}
+
+			valueQuery := jsonAlert.Get("query")
+			valueQueryRef := valueQuery.Get("refId").MustString()
+			for _, targetsObj := range panel.Get("targets").MustArray() {
+				target := simplejson.NewFromAny(targetsObj)
+
+				if target.Get("refId").MustString() == valueQueryRef {
+					dsName := ""
+					if target.Get("datasource").MustString() != "" {
+						dsName = target.Get("datasource").MustString()
+					} else if panel.Get("datasource").MustString() != "" {
+						dsName = panel.Get("datasource").MustString()
+					}
+
+					if datasourceId, err := e.lookupDatasourceId(dsName); err != nil {
+						return nil, err
+					} else {
+						valueQuery.SetPath([]string{"datasourceId"}, datasourceId)
+					}
+
+					targetQuery := target.Get("target").MustString()
+					if targetQuery != "" {
+						jsonAlert.SetPath([]string{"query", "query"}, targetQuery)
+					}
+				}
+			}
+
+			alert.Expression = jsonAlert
+
+			// validate
+			_, err := NewAlertRuleFromDBModel(alert)
+			if err == nil && alert.ValidToSave() {
+				alerts = append(alerts, alert)
+			} else {
+				e.log.Error("Failed to extract alerts from dashboard", "error", err)
+				return nil, errors.New("Failed to extract alerts from dashboard")
+			}
+
+		}
+	}
+
+	e.log.Debug("Extracted alerts from dashboard", "alertCount", len(alerts))
+	return alerts, nil
+}

+ 219 - 0
pkg/services/alerting/extractor_test.go

@@ -0,0 +1,219 @@
+package alerting
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestAlertRuleExtraction(t *testing.T) {
+
+	Convey("Parsing alert rules  from dashboard json", t, func() {
+		Convey("Parsing and validating alerts from dashboards", func() {
+			json := `{
+  "id": 57,
+  "title": "Graphite 4",
+  "originalTitle": "Graphite 4",
+  "tags": [
+    "graphite"
+  ],
+  "rows": [
+    {
+
+      "panels": [
+        {
+          "title": "Active desktop users",
+          "editable": true,
+          "type": "graph",
+          "id": 3,
+          "targets": [
+            {
+              "refId": "A",
+              "target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)"
+            }
+          ],
+          "datasource": null,
+          "alert": {
+            "name": "name1",
+            "description": "desc1",
+						"scheduler": 1,
+						"enabled": true,
+            "critical": {
+              "level": 20,
+              "op": ">"
+            },
+            "frequency": "60s",
+            "query": {
+              "from": "5m",
+              "refId": "A",
+              "to": "now"
+            },
+            "transform": {
+              "method": "avg",
+              "type": "aggregation"
+            },
+            "warn": {
+              "level": 10,
+              "op": ">"
+            }
+          }
+        },
+        {
+          "title": "Active mobile users",
+          "id": 4,
+          "targets": [
+            {
+              "refId": "A",
+              "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"
+            }
+          ],
+          "datasource": "graphite2",
+          "alert": {
+            "name": "name2",
+            "description": "desc2",
+						"scheduler": 0,
+						"enabled": true,
+            "critical": {
+              "level": 20,
+              "op": ">"
+            },
+            "frequency": "60s",
+            "query": {
+              "from": "5m",
+              "refId": "A",
+              "to": "now"
+            },
+            "transform": {
+              "method": "avg",
+              "name": "aggregation"
+            },
+            "warn": {
+              "level": 10,
+              "op": ">"
+            }
+          }
+        }
+      ],
+      "title": "Row"
+    },
+    {
+      "collapse": false,
+      "editable": true,
+      "height": "250px",
+      "panels": [
+        {
+          "datasource": "InfluxDB",
+          "id": 2,
+          "targets": [
+            {
+              "dsType": "influxdb",
+              "groupBy": [
+                {
+                  "params": [
+                    "$interval"
+                  ],
+                  "type": "time"
+                },
+                {
+                  "params": [
+                    "null"
+                  ],
+                  "type": "fill"
+                }
+              ],
+              "measurement": "cpu",
+              "policy": "default",
+              "query": "SELECT mean(\"value\") FROM \"cpu\" WHERE $timeFilter GROUP BY time($interval) fill(null)",
+              "refId": "A",
+              "resultFormat": "table",
+              "select": [
+                [
+                  {
+                    "params": [
+                      "value"
+                    ],
+                    "type": "field"
+                  },
+                  {
+                    "params": [],
+                    "type": "mean"
+                  }
+                ]
+              ],
+              "tags": [],
+              "target": ""
+            }
+          ],
+          "title": "Broken influxdb panel",
+          "transform": "table",
+          "type": "table"
+        }
+      ],
+      "title": "New row"
+    }
+  ]
+
+}`
+			dashJson, err := simplejson.NewJson([]byte(json))
+			So(err, ShouldBeNil)
+
+			dash := m.NewDashboardFromJson(dashJson)
+			extractor := NewDashAlertExtractor(dash, 1)
+
+			// mock data
+			defaultDs := &m.DataSource{Id: 12, OrgId: 2, Name: "I am default", IsDefault: true}
+			graphite2Ds := &m.DataSource{Id: 15, OrgId: 2, Name: "graphite2"}
+
+			bus.AddHandler("test", func(query *m.GetDataSourcesQuery) error {
+				query.Result = []*m.DataSource{defaultDs, graphite2Ds}
+				return nil
+			})
+
+			bus.AddHandler("test", func(query *m.GetDataSourceByNameQuery) error {
+				if query.Name == defaultDs.Name {
+					query.Result = defaultDs
+				}
+				if query.Name == graphite2Ds.Name {
+					query.Result = graphite2Ds
+				}
+				return nil
+			})
+
+			alerts, err := extractor.GetAlerts()
+
+			Convey("Get rules without error", func() {
+				So(err, ShouldBeNil)
+			})
+
+			Convey("all properties have been set", func() {
+				So(len(alerts), ShouldEqual, 2)
+
+				for _, v := range alerts {
+					So(v.DashboardId, ShouldEqual, 57)
+					So(v.Name, ShouldNotBeEmpty)
+					So(v.Description, ShouldNotBeEmpty)
+				}
+
+				Convey("should extract scheduler property", func() {
+					So(alerts[0].Scheduler, ShouldEqual, 1)
+					So(alerts[1].Scheduler, ShouldEqual, 0)
+				})
+
+				Convey("should extract panel idc", func() {
+					So(alerts[0].PanelId, ShouldEqual, 3)
+					So(alerts[1].PanelId, ShouldEqual, 4)
+				})
+
+				Convey("should extract name and desc", func() {
+					So(alerts[0].Name, ShouldEqual, "name1")
+					So(alerts[0].Description, ShouldEqual, "desc1")
+					So(alerts[1].Name, ShouldEqual, "name2")
+					So(alerts[1].Description, ShouldEqual, "desc2")
+				})
+			})
+		})
+	})
+}

+ 9 - 16
pkg/services/alerting/models.go

@@ -17,22 +17,15 @@ type AlertResult struct {
 	AlertJob    *AlertJob
 }
 
-type AlertRule struct {
-	Id           int64
-	OrgId        int64
-	DatasourceId int64
-	DashboardId  int64
-	PanelId      int64
+type Level struct {
+	Operator string
+	Level    float64
+}
+
+type AlertQuery struct {
 	Query        string
-	QueryRefId   string
-	WarnLevel    float64
-	CritLevel    float64
-	WarnOperator string
-	CritOperator string
-	Frequency    int64
-	Name         string
-	Description  string
-	QueryRange   int
+	DatasourceId int64
 	Aggregator   string
-	State        string
+	From         string
+	To           string
 }

+ 1 - 16
pkg/services/alerting/rule_reader.go → pkg/services/alerting/reader.go

@@ -49,22 +49,7 @@ func (arr *AlertRuleReader) Fetch() []*AlertRule {
 
 	res := make([]*AlertRule, len(cmd.Result))
 	for i, ruleDef := range cmd.Result {
-		model := &AlertRule{}
-		model.Id = ruleDef.Id
-		model.OrgId = ruleDef.OrgId
-		model.DatasourceId = ruleDef.DatasourceId
-		model.Query = ruleDef.Query
-		model.QueryRefId = ruleDef.QueryRefId
-		model.WarnLevel = ruleDef.WarnLevel
-		model.WarnOperator = ruleDef.WarnOperator
-		model.CritLevel = ruleDef.CritLevel
-		model.CritOperator = ruleDef.CritOperator
-		model.Frequency = ruleDef.Frequency
-		model.Name = ruleDef.Name
-		model.Description = ruleDef.Description
-		model.Aggregator = ruleDef.Aggregator
-		model.State = ruleDef.State
-		model.QueryRange = ruleDef.QueryRange
+		model, _ := NewAlertRuleFromDBModel(ruleDef)
 		res[i] = model
 	}
 

+ 6 - 5
pkg/services/alerting/scheduler.go

@@ -8,20 +8,22 @@ import (
 
 type SchedulerImpl struct {
 	jobs map[int64]*AlertJob
+	log  log.Logger
 }
 
 func NewScheduler() Scheduler {
 	return &SchedulerImpl{
 		jobs: make(map[int64]*AlertJob, 0),
+		log:  log.New("alerting.scheduler"),
 	}
 }
 
-func (s *SchedulerImpl) Update(rules []*AlertRule) {
-	log.Debug("Scheduler: Update()")
+func (s *SchedulerImpl) Update(alerts []*AlertRule) {
+	s.log.Debug("Scheduling update", "alerts.count", len(alerts))
 
 	jobs := make(map[int64]*AlertJob, 0)
 
-	for i, rule := range rules {
+	for i, rule := range alerts {
 		var job *AlertJob
 		if s.jobs[rule.Id] != nil {
 			job = s.jobs[rule.Id]
@@ -38,7 +40,6 @@ func (s *SchedulerImpl) Update(rules []*AlertRule) {
 		jobs[rule.Id] = job
 	}
 
-	log.Debug("Scheduler: Selected %d jobs", len(jobs))
 	s.jobs = jobs
 }
 
@@ -47,7 +48,7 @@ func (s *SchedulerImpl) Tick(tickTime time.Time, execQueue chan *AlertJob) {
 
 	for _, job := range s.jobs {
 		if now%job.Rule.Frequency == 0 && job.Running == false {
-			log.Trace("Scheduler: Putting job on to exec queue: %s", job.Rule.Name)
+			s.log.Debug("Scheduler: Putting job on to exec queue", "name", job.Rule.Name)
 			execQueue <- job
 		}
 	}

+ 71 - 0
pkg/services/alerting/transformers/aggregation.go

@@ -0,0 +1,71 @@
+package transformer
+
+import (
+	"fmt"
+	"math"
+
+	"github.com/grafana/grafana/pkg/tsdb"
+)
+
+func NewAggregationTransformer(method string) *AggregationTransformer {
+	return &AggregationTransformer{
+		Method: method,
+	}
+}
+
+type AggregationTransformer struct {
+	Method string
+}
+
+func (at *AggregationTransformer) Transform(timeserie *tsdb.TimeSeries) (float64, error) {
+
+	if at.Method == "avg" {
+		sum := float64(0)
+		for _, point := range timeserie.Points {
+			sum += point[0]
+		}
+
+		return sum / float64(len(timeserie.Points)), nil
+	}
+
+	if at.Method == "sum" {
+		sum := float64(0)
+
+		for _, v := range timeserie.Points {
+			sum += v[0]
+		}
+
+		return sum, nil
+	}
+
+	if at.Method == "min" {
+		min := timeserie.Points[0][0]
+
+		for _, v := range timeserie.Points {
+			if v[0] < min {
+				min = v[0]
+			}
+		}
+
+		return min, nil
+	}
+
+	if at.Method == "max" {
+		max := timeserie.Points[0][0]
+
+		for _, v := range timeserie.Points {
+			if v[0] > max {
+				max = v[0]
+			}
+		}
+
+		return max, nil
+	}
+
+	if at.Method == "mean" {
+		midPosition := int64(math.Floor(float64(len(timeserie.Points)) / float64(2)))
+		return timeserie.Points[midPosition][0], nil
+	}
+
+	return float64(0), fmt.Errorf("Missing method")
+}

+ 7 - 0
pkg/services/alerting/transformers/transformer.go

@@ -0,0 +1,7 @@
+package transformer
+
+import "github.com/grafana/grafana/pkg/tsdb"
+
+type Transformer interface {
+	Transform(timeserie *tsdb.TimeSeries) (float64, error)
+}

+ 1 - 0
pkg/services/search/handlers.go

@@ -44,6 +44,7 @@ func searchHandler(query *Query) error {
 		IsStarred:    query.IsStarred,
 		OrgId:        query.OrgId,
 		DashboardIds: query.DashboardIds,
+		Limit:        query.Limit,
 	}
 
 	if err := bus.Dispatch(&dashQuery); err != nil {

+ 1 - 0
pkg/services/search/models.go

@@ -42,6 +42,7 @@ type FindPersistedDashboardsQuery struct {
 	UserId       int64
 	IsStarred    bool
 	DashboardIds []int
+	Limit        int
 
 	Result HitList
 }

+ 25 - 16
pkg/services/sqlstore/alert_rule.go → pkg/services/sqlstore/alert.go

@@ -64,7 +64,7 @@ func HeartBeat(query *m.HeartBeatCommand) error {
 */
 
 func GetAlertById(query *m.GetAlertByIdQuery) error {
-	alert := m.AlertRule{}
+	alert := m.Alert{}
 	has, err := x.Id(query.Id).Get(&alert)
 	if !has {
 		return fmt.Errorf("could not find alert")
@@ -78,8 +78,8 @@ func GetAlertById(query *m.GetAlertByIdQuery) error {
 }
 
 func GetAllAlertQueryHandler(query *m.GetAllAlertsQuery) error {
-	var alerts []*m.AlertRule
-	err := x.Sql("select * from alert_rule").Find(&alerts)
+	var alerts []*m.Alert
+	err := x.Sql("select * from alert").Find(&alerts)
 	if err != nil {
 		return err
 	}
@@ -90,7 +90,7 @@ func GetAllAlertQueryHandler(query *m.GetAllAlertsQuery) error {
 
 func DeleteAlertById(cmd *m.DeleteAlertCommand) error {
 	return inTransaction(func(sess *xorm.Session) error {
-		if _, err := sess.Exec("DELETE FROM alert_rule WHERE id = ?", cmd.AlertId); err != nil {
+		if _, err := sess.Exec("DELETE FROM alert WHERE id = ?", cmd.AlertId); err != nil {
 			return err
 		}
 
@@ -103,7 +103,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 	params := make([]interface{}, 0)
 
 	sql.WriteString(`SELECT *
-						from alert_rule
+						from alert
 						`)
 
 	sql.WriteString(`WHERE org_id = ?`)
@@ -131,7 +131,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 		sql.WriteString(")")
 	}
 
-	alerts := make([]*m.AlertRule, 0)
+	alerts := make([]*m.Alert, 0)
 	if err := x.Sql(sql.String(), params...).Find(&alerts); err != nil {
 		return err
 	}
@@ -141,15 +141,17 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
 }
 
 func DeleteAlertDefinition(dashboardId int64, sess *xorm.Session) error {
-	alerts := make([]*m.AlertRule, 0)
+	alerts := make([]*m.Alert, 0)
 	sess.Where("dashboard_id = ?", dashboardId).Find(&alerts)
 
 	for _, alert := range alerts {
-		_, err := sess.Exec("DELETE FROM alert_rule WHERE id = ? ", alert.Id)
+		_, err := sess.Exec("DELETE FROM alert WHERE id = ? ", alert.Id)
 		if err != nil {
 			return err
 		}
 
+		sqlog.Debug("Alert deleted (due to dashboard deletion)", "name", alert.Name, "id", alert.Id)
+
 		if err := SaveAlertChange("DELETED", alert, sess); err != nil {
 			return err
 		}
@@ -172,21 +174,22 @@ func SaveAlerts(cmd *m.SaveAlertsCommand) error {
 	})
 }
 
-func upsertAlerts(alerts []*m.AlertRule, posted []*m.AlertRule, sess *xorm.Session) error {
+func upsertAlerts(alerts []*m.Alert, posted []*m.Alert, sess *xorm.Session) error {
 	for _, alert := range posted {
 		update := false
-		var alertToUpdate *m.AlertRule
+		var alertToUpdate *m.Alert
 
 		for _, k := range alerts {
 			if alert.PanelId == k.PanelId {
 				update = true
 				alert.Id = k.Id
 				alertToUpdate = k
+				break
 			}
 		}
 
 		if update {
-			if alertToUpdate.Equals(alert) {
+			if alertToUpdate.ContainsUpdates(alert) {
 				alert.Updated = time.Now()
 				alert.State = alertToUpdate.State
 				_, err := sess.Id(alert.Id).Update(alert)
@@ -194,6 +197,7 @@ func upsertAlerts(alerts []*m.AlertRule, posted []*m.AlertRule, sess *xorm.Sessi
 					return err
 				}
 
+				sqlog.Debug("Alert updated", "name", alert.Name, "id", alert.Id)
 				SaveAlertChange("UPDATED", alert, sess)
 			}
 
@@ -205,6 +209,8 @@ func upsertAlerts(alerts []*m.AlertRule, posted []*m.AlertRule, sess *xorm.Sessi
 			if err != nil {
 				return err
 			}
+
+			sqlog.Debug("Alert inserted", "name", alert.Name, "id", alert.Id)
 			SaveAlertChange("CREATED", alert, sess)
 		}
 	}
@@ -212,22 +218,25 @@ func upsertAlerts(alerts []*m.AlertRule, posted []*m.AlertRule, sess *xorm.Sessi
 	return nil
 }
 
-func deleteMissingAlerts(alerts []*m.AlertRule, posted []*m.AlertRule, sess *xorm.Session) error {
+func deleteMissingAlerts(alerts []*m.Alert, posted []*m.Alert, sess *xorm.Session) error {
 	for _, missingAlert := range alerts {
 		missing := true
 
 		for _, k := range posted {
 			if missingAlert.PanelId == k.PanelId {
 				missing = false
+				break
 			}
 		}
 
 		if missing {
-			_, err := sess.Exec("DELETE FROM alert_rule WHERE id = ?", missingAlert.Id)
+			_, err := sess.Exec("DELETE FROM alert WHERE id = ?", missingAlert.Id)
 			if err != nil {
 				return err
 			}
 
+			sqlog.Debug("Alert deleted", "name", missingAlert.Name, "id", missingAlert.Id)
+
 			err = SaveAlertChange("DELETED", missingAlert, sess)
 			if err != nil {
 				return err
@@ -238,12 +247,12 @@ func deleteMissingAlerts(alerts []*m.AlertRule, posted []*m.AlertRule, sess *xor
 	return nil
 }
 
-func GetAlertsByDashboardId2(dashboardId int64, sess *xorm.Session) ([]*m.AlertRule, error) {
-	alerts := make([]*m.AlertRule, 0)
+func GetAlertsByDashboardId2(dashboardId int64, sess *xorm.Session) ([]*m.Alert, error) {
+	alerts := make([]*m.Alert, 0)
 	err := sess.Where("dashboard_id = ?", dashboardId).Find(&alerts)
 
 	if err != nil {
-		return []*m.AlertRule{}, err
+		return []*m.Alert{}, err
 	}
 
 	return alerts, nil

+ 3 - 3
pkg/services/sqlstore/alert_rule_changes.go

@@ -39,7 +39,7 @@ func GetAlertRuleChanges(query *m.GetAlertChangesQuery) error {
 		params = append(params, query.Limit)
 	}
 
-	alertChanges := make([]*m.AlertRuleChange, 0)
+	alertChanges := make([]*m.AlertChange, 0)
 	if err := x.Sql(sql.String(), params...).Find(&alertChanges); err != nil {
 		return err
 	}
@@ -48,8 +48,8 @@ func GetAlertRuleChanges(query *m.GetAlertChangesQuery) error {
 	return nil
 }
 
-func SaveAlertChange(change string, alert *m.AlertRule, sess *xorm.Session) error {
-	_, err := sess.Insert(&m.AlertRuleChange{
+func SaveAlertChange(change string, alert *m.Alert, sess *xorm.Session) error {
+	_, err := sess.Insert(&m.AlertChange{
 		OrgId:   alert.OrgId,
 		Type:    change,
 		Created: time.Now(),

+ 6 - 15
pkg/services/sqlstore/alert_rule_changes_test.go

@@ -20,22 +20,13 @@ func TestAlertRuleChangesDataAccess(t *testing.T) {
 		var err error
 
 		Convey("When dashboard is removed", func() {
-			items := []*m.AlertRule{
+			items := []*m.Alert{
 				{
-					PanelId:      1,
-					DashboardId:  testDash.Id,
-					Query:        "Query",
-					QueryRefId:   "A",
-					WarnLevel:    30,
-					CritLevel:    50,
-					WarnOperator: ">",
-					CritOperator: ">",
-					Frequency:    10,
-					Name:         "Alerting title",
-					Description:  "Alerting description",
-					QueryRange:   3600,
-					Aggregator:   "avg",
-					OrgId:        FakeOrgId,
+					PanelId:     1,
+					DashboardId: testDash.Id,
+					Name:        "Alerting title",
+					Description: "Alerting description",
+					OrgId:       FakeOrgId,
 				},
 			}
 

+ 22 - 48
pkg/services/sqlstore/alert_rule_test.go

@@ -3,34 +3,25 @@ package sqlstore
 import (
 	"testing"
 
+	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	. "github.com/smartystreets/goconvey/convey"
 )
 
 func TestAlertingDataAccess(t *testing.T) {
-
 	Convey("Testing Alerting data access", t, func() {
 		InitTestDB(t)
 
 		testDash := insertTestDashboard("dashboard with alerts", 1, "alert")
 
-		items := []*m.AlertRule{
+		items := []*m.Alert{
 			{
-				PanelId:      1,
-				DashboardId:  testDash.Id,
-				OrgId:        testDash.OrgId,
-				Query:        "Query",
-				QueryRefId:   "A",
-				WarnLevel:    30,
-				CritLevel:    50,
-				WarnOperator: ">",
-				CritOperator: ">",
-				Frequency:    10,
-				Name:         "Alerting title",
-				Description:  "Alerting description",
-				QueryRange:   3600,
-				Aggregator:   "avg",
-				DatasourceId: 42,
+				PanelId:     1,
+				DashboardId: testDash.Id,
+				OrgId:       testDash.OrgId,
+				Name:        "Alerting title",
+				Description: "Alerting description",
+				Expression:  simplejson.New(),
 			},
 		}
 
@@ -58,25 +49,14 @@ func TestAlertingDataAccess(t *testing.T) {
 
 			alert := alertQuery.Result[0]
 			So(err2, ShouldBeNil)
-			So(alert.Frequency, ShouldEqual, 10)
-			So(alert.WarnLevel, ShouldEqual, 30)
-			So(alert.CritLevel, ShouldEqual, 50)
-			So(alert.WarnOperator, ShouldEqual, ">")
-			So(alert.CritOperator, ShouldEqual, ">")
-			So(alert.Query, ShouldEqual, "Query")
-			So(alert.QueryRefId, ShouldEqual, "A")
 			So(alert.Name, ShouldEqual, "Alerting title")
 			So(alert.Description, ShouldEqual, "Alerting description")
-			So(alert.QueryRange, ShouldEqual, 3600)
-			So(alert.Aggregator, ShouldEqual, "avg")
 			So(alert.State, ShouldEqual, "OK")
-			So(alert.DatasourceId, ShouldEqual, 42)
 		})
 
 		Convey("Alerts with same dashboard id and panel id should update", func() {
 			modifiedItems := items
-			modifiedItems[0].Query = "Updated Query"
-			modifiedItems[0].State = "ALERT"
+			modifiedItems[0].Name = "Name"
 
 			modifiedCmd := m.SaveAlertsCommand{
 				DashboardId: testDash.Id,
@@ -97,7 +77,7 @@ func TestAlertingDataAccess(t *testing.T) {
 
 				So(err2, ShouldBeNil)
 				So(len(query.Result), ShouldEqual, 1)
-				So(query.Result[0].Query, ShouldEqual, "Updated Query")
+				So(query.Result[0].Name, ShouldEqual, "Name")
 
 				Convey("Alert state should not be updated", func() {
 					So(query.Result[0].State, ShouldEqual, "OK")
@@ -116,24 +96,27 @@ func TestAlertingDataAccess(t *testing.T) {
 		})
 
 		Convey("Multiple alerts per dashboard", func() {
-			multipleItems := []*m.AlertRule{
+			multipleItems := []*m.Alert{
 				{
 					DashboardId: testDash.Id,
 					PanelId:     1,
-					Query:       "1",
+					Name:        "1",
 					OrgId:       1,
+					Expression:  simplejson.New(),
 				},
 				{
 					DashboardId: testDash.Id,
 					PanelId:     2,
-					Query:       "2",
+					Name:        "2",
 					OrgId:       1,
+					Expression:  simplejson.New(),
 				},
 				{
 					DashboardId: testDash.Id,
 					PanelId:     3,
-					Query:       "3",
+					Name:        "3",
 					OrgId:       1,
+					Expression:  simplejson.New(),
 				},
 			}
 
@@ -178,21 +161,12 @@ func TestAlertingDataAccess(t *testing.T) {
 		})
 
 		Convey("When dashboard is removed", func() {
-			items := []*m.AlertRule{
+			items := []*m.Alert{
 				{
-					PanelId:      1,
-					DashboardId:  testDash.Id,
-					Query:        "Query",
-					QueryRefId:   "A",
-					WarnLevel:    30,
-					CritLevel:    50,
-					WarnOperator: ">",
-					CritOperator: ">",
-					Frequency:    10,
-					Name:         "Alerting title",
-					Description:  "Alerting description",
-					QueryRange:   3600,
-					Aggregator:   "avg",
+					PanelId:     1,
+					DashboardId: testDash.Id,
+					Name:        "Alerting title",
+					Description: "Alerting description",
 				},
 			}
 

+ 3 - 2
pkg/services/sqlstore/alert_state.go

@@ -2,10 +2,11 @@ package sqlstore
 
 import (
 	"fmt"
+	"time"
+
 	"github.com/go-xorm/xorm"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
-	"time"
 )
 
 func init() {
@@ -19,7 +20,7 @@ func SetNewAlertState(cmd *m.UpdateAlertStateCommand) error {
 			return fmt.Errorf("new state is invalid")
 		}
 
-		alert := m.AlertRule{}
+		alert := m.Alert{}
 		has, err := sess.Id(cmd.AlertId).Get(&alert)
 		if !has {
 			return fmt.Errorf("Could not find alert")

+ 6 - 15
pkg/services/sqlstore/alert_state_test.go

@@ -13,22 +13,13 @@ func TestAlertingStateAccess(t *testing.T) {
 
 		testDash := insertTestDashboard("dashboard with alerts", 1, "alert")
 
-		items := []*m.AlertRule{
+		items := []*m.Alert{
 			{
-				PanelId:      1,
-				DashboardId:  testDash.Id,
-				OrgId:        testDash.OrgId,
-				Query:        "Query",
-				QueryRefId:   "A",
-				WarnLevel:    30,
-				CritLevel:    50,
-				WarnOperator: ">",
-				CritOperator: ">",
-				Frequency:    10,
-				Name:         "Alerting title",
-				Description:  "Alerting description",
-				QueryRange:   3600,
-				Aggregator:   "avg",
+				PanelId:     1,
+				DashboardId: testDash.Id,
+				OrgId:       testDash.OrgId,
+				Name:        "Alerting title",
+				Description: "Alerting description",
 			},
 		}
 

+ 7 - 1
pkg/services/sqlstore/dashboard.go

@@ -123,6 +123,11 @@ type DashboardSearchProjection struct {
 }
 
 func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
+	limit := query.Limit
+	if limit == 0 {
+		limit = 1000
+	}
+
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)
 
@@ -165,7 +170,8 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
 		params = append(params, "%"+query.Title+"%")
 	}
 
-	sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT 1000"))
+	sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT ?"))
+	params = append(params, limit)
 
 	var res []DashboardSearchProjection
 

+ 0 - 415
pkg/services/sqlstore/dashboard_parser_test.go

@@ -1,415 +0,0 @@
-package sqlstore
-
-import (
-	"testing"
-
-	"github.com/grafana/grafana/pkg/components/simplejson"
-	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/alerting"
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-func TestAlertModel(t *testing.T) {
-
-	Convey("Parsing alerts from dashboard", t, func() {
-		json := `{
-  "id": 57,
-  "title": "Graphite 4",
-  "originalTitle": "Graphite 4",
-  "tags": [
-    "graphite"
-  ],
-  "style": "dark",
-  "timezone": "browser",
-  "editable": true,
-  "hideControls": false,
-  "sharedCrosshair": false,
-  "rows": [
-    {
-      "collapse": false,
-      "editable": true,
-      "height": "250px",
-      "panels": [
-        {
-          "title": "Active desktop users",
-          "error": false,
-          "span": 6,
-          "editable": true,
-          "type": "graph",
-          "isNew": true,
-          "id": 3,
-          "targets": [
-            {
-              "refId": "A",
-              "target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)"
-            }
-          ],
-          "datasource": null,
-          "renderer": "flot",
-          "yaxes": [
-            {
-              "label": null,
-              "show": true,
-              "logBase": 1,
-              "min": null,
-              "max": null,
-              "format": "short"
-            },
-            {
-              "label": null,
-              "show": true,
-              "logBase": 1,
-              "min": null,
-              "max": null,
-              "format": "short"
-            }
-          ],
-          "xaxis": {
-            "show": true
-          },
-          "grid": {
-            "threshold1": null,
-            "threshold2": null,
-            "threshold1Color": "rgba(216, 200, 27, 0.27)",
-            "threshold2Color": "rgba(234, 112, 112, 0.22)"
-          },
-          "lines": true,
-          "fill": 1,
-          "linewidth": 2,
-          "points": false,
-          "pointradius": 5,
-          "bars": false,
-          "stack": false,
-          "percentage": false,
-          "legend": {
-            "show": true,
-            "values": false,
-            "min": false,
-            "max": false,
-            "current": false,
-            "total": false,
-            "avg": false
-          },
-          "nullPointMode": "connected",
-          "steppedLine": false,
-          "tooltip": {
-            "value_type": "cumulative",
-            "shared": true,
-            "msResolution": false
-          },
-          "timeFrom": null,
-          "timeShift": null,
-          "aliasColors": {},
-          "seriesOverrides": [],
-          "alerting": {
-            "queryRef": "A",
-            "warnLevel": 30,
-            "critLevel": 50,
-            "warnOperator": ">",
-            "critOperator": ">",
-            "aggregator": "sum",
-            "queryRange": 3600,
-            "frequency": 10,
-            "name": "active desktop users",
-            "description": "restart webservers"
-          },
-          "links": []
-        },
-        {
-          "title": "Active mobile users",
-          "error": false,
-          "span": 6,
-          "editable": true,
-          "type": "graph",
-          "isNew": true,
-          "id": 4,
-          "targets": [
-            {
-              "refId": "A",
-              "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"
-            }
-          ],
-          "datasource": "graphite2",
-          "renderer": "flot",
-          "yaxes": [
-            {
-              "label": null,
-              "show": true,
-              "logBase": 1,
-              "min": null,
-              "max": null,
-              "format": "short"
-            },
-            {
-              "label": null,
-              "show": true,
-              "logBase": 1,
-              "min": null,
-              "max": null,
-              "format": "short"
-            }
-          ],
-          "xaxis": {
-            "show": true
-          },
-          "grid": {
-            "threshold1": null,
-            "threshold2": null,
-            "threshold1Color": "rgba(216, 200, 27, 0.27)",
-            "threshold2Color": "rgba(234, 112, 112, 0.22)"
-          },
-          "lines": true,
-          "fill": 1,
-          "linewidth": 2,
-          "points": false,
-          "pointradius": 5,
-          "bars": false,
-          "stack": false,
-          "percentage": false,
-          "legend": {
-            "show": true,
-            "values": false,
-            "min": false,
-            "max": false,
-            "current": false,
-            "total": false,
-            "avg": false
-          },
-          "nullPointMode": "connected",
-          "steppedLine": false,
-          "tooltip": {
-            "value_type": "cumulative",
-            "shared": true,
-            "msResolution": false
-          },
-          "timeFrom": null,
-          "timeShift": null,
-          "aliasColors": {
-            "mobile": "#EAB839"
-          },
-          "seriesOverrides": [],
-          "alerting": {
-            "queryRef": "A",
-            "warnOperator": ">",
-            "critOperator": ">",
-            "warnLevel": 300,
-            "critLevel": 500,
-            "aggregator": "avg",
-            "queryRange": 3600,
-            "frequency": 10,
-            "name": "active mobile users",
-            "description": "restart itunes"
-          },
-          "links": []
-        }
-      ],
-      "title": "Row"
-    },
-    {
-      "collapse": false,
-      "editable": true,
-      "height": "250px",
-      "panels": [
-        {
-          "columns": [],
-          "datasource": "InfluxDB",
-          "editable": true,
-          "error": false,
-          "fontSize": "100%",
-          "id": 2,
-          "isNew": true,
-          "pageSize": null,
-          "scroll": true,
-          "showHeader": true,
-          "sort": {
-            "col": 0,
-            "desc": true
-          },
-          "span": 6,
-          "styles": [
-            {
-              "dateFormat": "YYYY-MM-DD HH:mm:ss",
-              "pattern": "Time",
-              "type": "date"
-            },
-            {
-              "colorMode": null,
-              "colors": [
-                "rgba(245, 54, 54, 0.9)",
-                "rgba(237, 129, 40, 0.89)",
-                "rgba(50, 172, 45, 0.97)"
-              ],
-              "decimals": 2,
-              "pattern": "/.*/",
-              "thresholds": [],
-              "type": "number",
-              "unit": "short"
-            }
-          ],
-          "targets": [
-            {
-              "dsType": "influxdb",
-              "groupBy": [
-                {
-                  "params": [
-                    "$interval"
-                  ],
-                  "type": "time"
-                },
-                {
-                  "params": [
-                    "null"
-                  ],
-                  "type": "fill"
-                }
-              ],
-              "measurement": "cpu",
-              "policy": "default",
-              "query": "SELECT mean(\"value\") FROM \"cpu\" WHERE $timeFilter GROUP BY time($interval) fill(null)",
-              "refId": "A",
-              "resultFormat": "table",
-              "select": [
-                [
-                  {
-                    "params": [
-                      "value"
-                    ],
-                    "type": "field"
-                  },
-                  {
-                    "params": [],
-                    "type": "mean"
-                  }
-                ]
-              ],
-              "tags": [],
-              "target": ""
-            }
-          ],
-          "title": "Broken influxdb panel",
-          "transform": "table",
-          "type": "table",
-          "links": []
-        }
-      ],
-      "title": "New row"
-    }
-  ],
-  "time": {
-    "from": "now-1h",
-    "to": "now"
-  },
-  "timepicker": {
-    "now": true,
-    "nowDelay": "5m",
-    "refresh_intervals": [
-      "5s",
-      "10s",
-      "30s",
-      "1m",
-      "5m",
-      "15m",
-      "30m",
-      "1h",
-      "2h",
-      "1d",
-      "7d"
-    ],
-    "time_options": [
-      "5m",
-      "15m",
-      "1h",
-      "6h",
-      "12h",
-      "24h",
-      "2d",
-      "7d",
-      "30d"
-    ]
-  },
-  "templating": {
-    "list": []
-  },
-  "annotations": {
-    "list": []
-  },
-  "schemaVersion": 12,
-  "version": 16,
-  "links": []
-}`
-		dashboardJson, _ := simplejson.NewJson([]byte(json))
-		cmd := &m.SaveDashboardCommand{
-			Dashboard: dashboardJson,
-			UserId:    1,
-			OrgId:     1,
-			Overwrite: true,
-			Result: &m.Dashboard{
-				Id: 1,
-			},
-		}
-
-		InitTestDB(t)
-
-		AddDataSource(&m.AddDataSourceCommand{
-			Name:      "graphite2",
-			OrgId:     1,
-			Type:      m.DS_INFLUXDB,
-			Access:    m.DS_ACCESS_DIRECT,
-			Url:       "http://test",
-			IsDefault: false,
-			Database:  "site",
-		})
-
-		AddDataSource(&m.AddDataSourceCommand{
-			Name:      "InfluxDB",
-			OrgId:     1,
-			Type:      m.DS_GRAPHITE,
-			Access:    m.DS_ACCESS_DIRECT,
-			Url:       "http://test",
-			IsDefault: true,
-		})
-
-		alerts := alerting.ParseAlertsFromDashboard(cmd)
-
-		Convey("all properties have been set", func() {
-			So(alerts, ShouldNotBeEmpty)
-			So(len(alerts), ShouldEqual, 2)
-
-			for _, v := range alerts {
-				So(v.DashboardId, ShouldEqual, 1)
-				So(v.PanelId, ShouldNotEqual, 0)
-
-				So(v.WarnLevel, ShouldNotBeEmpty)
-				So(v.CritLevel, ShouldNotBeEmpty)
-
-				So(v.Aggregator, ShouldNotBeEmpty)
-				So(v.Query, ShouldNotBeEmpty)
-				So(v.QueryRefId, ShouldNotBeEmpty)
-				So(v.QueryRange, ShouldNotEqual, 0)
-				So(v.Frequency, ShouldNotEqual, 0)
-				So(v.Name, ShouldNotBeEmpty)
-				So(v.Description, ShouldNotBeEmpty)
-			}
-
-			So(alerts[0].WarnLevel, ShouldEqual, 30)
-			So(alerts[1].WarnLevel, ShouldEqual, 300)
-
-			So(alerts[0].Frequency, ShouldEqual, 10)
-			So(alerts[1].Frequency, ShouldEqual, 10)
-
-			So(alerts[0].CritLevel, ShouldEqual, 50)
-			So(alerts[1].CritLevel, ShouldEqual, 500)
-
-			So(alerts[0].CritOperator, ShouldEqual, ">")
-			So(alerts[1].CritOperator, ShouldEqual, ">")
-			So(alerts[0].WarnOperator, ShouldEqual, ">")
-			So(alerts[1].WarnOperator, ShouldEqual, ">")
-
-			So(alerts[0].Query, ShouldEqual, `{"refId":"A","target":"aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)"}`)
-			So(alerts[1].Query, ShouldEqual, `{"refId":"A","target":"aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}`)
-
-			So(alerts[0].DatasourceId, ShouldEqual, 2)
-			So(alerts[1].DatasourceId, ShouldEqual, 1)
-		})
-	})
-}

+ 1 - 3
pkg/services/sqlstore/datasource_test.go

@@ -12,8 +12,6 @@ import (
 )
 
 func InitTestDB(t *testing.T) {
-
-	t.Log("InitTestDB")
 	x, err := xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr)
 	//x, err := xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr)
 	//x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr)
@@ -24,7 +22,7 @@ func InitTestDB(t *testing.T) {
 
 	sqlutil.CleanDB(x)
 
-	if err := SetEngine(x, false); err != nil {
+	if err := SetEngine(x); err != nil {
 		t.Fatal(err)
 	}
 }

+ 8 - 14
pkg/services/sqlstore/migrations/alert_mig.go

@@ -7,35 +7,29 @@ import (
 func addAlertMigrations(mg *Migrator) {
 
 	alertV1 := Table{
-		Name: "alert_rule",
+		Name: "alert",
 		Columns: []*Column{
 			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
 			{Name: "dashboard_id", Type: DB_BigInt, Nullable: false},
-			{Name: "datasource_id", Type: DB_BigInt, Nullable: false},
 			{Name: "panel_id", Type: DB_BigInt, Nullable: false},
 			{Name: "org_id", Type: DB_BigInt, Nullable: false},
-			{Name: "query", Type: DB_Text, Nullable: false},
-			{Name: "query_ref_id", Type: DB_NVarchar, Length: 255, Nullable: false},
-			{Name: "warn_level", Type: DB_Float, Nullable: false},
-			{Name: "warn_operator", Type: DB_NVarchar, Length: 10, Nullable: false},
-			{Name: "crit_level", Type: DB_Float, Nullable: false},
-			{Name: "crit_operator", Type: DB_NVarchar, Length: 10, Nullable: false},
-			{Name: "frequency", Type: DB_BigInt, Nullable: false},
 			{Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false},
 			{Name: "description", Type: DB_NVarchar, Length: 255, Nullable: false},
-			{Name: "query_range", Type: DB_Int, Nullable: false},
-			{Name: "aggregator", Type: DB_NVarchar, Length: 255, Nullable: false},
 			{Name: "state", Type: DB_NVarchar, Length: 255, Nullable: false},
+			{Name: "expression", Type: DB_Text, Nullable: false},
+			{Name: "scheduler", Type: DB_BigInt, Nullable: false},
+			{Name: "frequency", Type: DB_BigInt, Nullable: false},
+			{Name: "enabled", Type: DB_Bool, Nullable: false},
 			{Name: "created", Type: DB_DateTime, Nullable: false},
 			{Name: "updated", Type: DB_DateTime, Nullable: false},
 		},
 	}
 
 	// create table
-	mg.AddMigration("create alert_rule table v2", NewAddTableMigration(alertV1))
+	mg.AddMigration("create alert table v1", NewAddTableMigration(alertV1))
 
 	alert_changes := Table{
-		Name: "alert_rule_change",
+		Name: "alert_change",
 		Columns: []*Column{
 			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
 			{Name: "alert_id", Type: DB_BigInt, Nullable: false},
@@ -45,7 +39,7 @@ func addAlertMigrations(mg *Migrator) {
 		},
 	}
 
-	mg.AddMigration("create alert_rules_updates table v1", NewAddTableMigration(alert_changes))
+	mg.AddMigration("create alert_change table v1", NewAddTableMigration(alert_changes))
 
 	alert_state_log := Table{
 		Name: "alert_state",

+ 0 - 2
pkg/services/sqlstore/migrations/migrations_test.go

@@ -6,7 +6,6 @@ import (
 	"github.com/go-xorm/xorm"
 	. "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
 	"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
-	"github.com/inconshreveable/log15"
 
 	. "github.com/smartystreets/goconvey/convey"
 	//"github.com/grafana/grafana/pkg/log"
@@ -30,7 +29,6 @@ func TestMigrations(t *testing.T) {
 			sqlutil.CleanDB(x)
 
 			mg := NewMigrator(x)
-			mg.Logger.SetHandler(log15.DiscardHandler())
 			AddMigrations(mg)
 
 			err = mg.Start()

+ 1 - 1
pkg/services/sqlstore/migrator/migrator.go

@@ -107,7 +107,7 @@ func (mg *Migrator) Start() error {
 }
 
 func (mg *Migrator) exec(m Migration) error {
-	log.Info("Executing migration", "id", m.Id())
+	mg.Logger.Info("Executing migration", "id", m.Id())
 
 	err := mg.inTransaction(func(sess *xorm.Session) error {
 

+ 2 - 2
pkg/services/sqlstore/sqlstore.go

@@ -78,7 +78,7 @@ func NewEngine() {
 		os.Exit(1)
 	}
 
-	err = SetEngine(x, setting.Env == setting.DEV)
+	err = SetEngine(x)
 
 	if err != nil {
 		sqlog.Error("Fail to initialize orm engine", "error", err)
@@ -86,7 +86,7 @@ func NewEngine() {
 	}
 }
 
-func SetEngine(engine *xorm.Engine, enableLog bool) (err error) {
+func SetEngine(engine *xorm.Engine) (err error) {
 	x = engine
 	dialect = migrator.NewDialect(x.DriverName())
 

+ 6 - 7
pkg/tsdb/graphite/graphite.go

@@ -5,9 +5,9 @@ import (
 	"io/ioutil"
 	"net/http"
 	"net/url"
+	"strings"
 	"time"
 
-	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/tsdb"
 )
@@ -31,7 +31,7 @@ func (e *GraphiteExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryC
 	result := &tsdb.BatchResult{}
 
 	params := url.Values{
-		"from":          []string{context.TimeRange.From},
+		"from":          []string{formatTimeRange(context.TimeRange.From)},
 		"until":         []string{context.TimeRange.To},
 		"format":        []string{"json"},
 		"maxDataPoints": []string{"500"},
@@ -39,7 +39,7 @@ func (e *GraphiteExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryC
 
 	for _, query := range queries {
 		params["target"] = []string{
-			getTargetFromQuery(query.Query),
+			query.Query,
 		}
 	}
 
@@ -60,7 +60,7 @@ func (e *GraphiteExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryC
 	var data []TargetResponseDTO
 	err = json.Unmarshal(body, &data)
 	if err != nil {
-		glog.Info("Failed to unmarshal graphite response", "error", err)
+		glog.Info("Failed to unmarshal graphite response", "error", err, "body", string(body))
 		result.Error = err
 		return result
 	}
@@ -78,7 +78,6 @@ func (e *GraphiteExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryC
 	return result
 }
 
-func getTargetFromQuery(query string) string {
-	json, _ := simplejson.NewJson([]byte(query))
-	return json.Get("target").MustString()
+func formatTimeRange(input string) string {
+	return strings.Replace(strings.Replace(input, "m", "min", -1), "M", "mon", -1)
 }

+ 123 - 0
public/app/core/components/query_part/query_part.ts

@@ -0,0 +1,123 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import _ from 'lodash';
+
+export class QueryPartDef {
+  type: string;
+  params: any[];
+  defaultParams: any[];
+  renderer: any;
+  category: any;
+  addStrategy: any;
+
+  constructor(options: any) {
+    this.type = options.type;
+    this.params = options.params;
+    this.defaultParams = options.defaultParams;
+    this.renderer = options.renderer;
+    this.category = options.category;
+    this.addStrategy = options.addStrategy;
+  }
+}
+
+export class QueryPart {
+  part: any;
+  def: QueryPartDef;
+  params: any[];
+  text: string;
+
+  constructor(part: any, def: any) {
+    this.part = part;
+    this.def = def;
+    if (!this.def) {
+      throw {message: 'Could not find query part ' + part.type};
+    }
+
+    part.params = part.params || _.clone(this.def.defaultParams);
+    this.params = part.params;
+    this.updateText();
+  }
+
+  render(innerExpr: string) {
+    return this.def.renderer(this, innerExpr);
+  }
+
+  hasMultipleParamsInString (strValue, index) {
+    if (strValue.indexOf(',') === -1) {
+      return false;
+    }
+
+    return this.def.params[index + 1] && this.def.params[index + 1].optional;
+  }
+
+  updateParam (strValue, index) {
+    // handle optional parameters
+    // if string contains ',' and next param is optional, split and update both
+    if (this.hasMultipleParamsInString(strValue, index)) {
+      _.each(strValue.split(','), function(partVal: string, idx) {
+        this.updateParam(partVal.trim(), idx);
+      }, this);
+      return;
+    }
+
+    if (strValue === '' && this.def.params[index].optional) {
+      this.params.splice(index, 1);
+    } else {
+      this.params[index] = strValue;
+    }
+
+    this.part.params = this.params;
+    this.updateText();
+  }
+
+  updateText() {
+    if (this.params.length === 0) {
+      this.text = this.def.type + '()';
+      return;
+    }
+
+    var text = this.def.type + '(';
+    text += this.params.join(', ');
+    text += ')';
+    this.text = text;
+  }
+}
+
+export function functionRenderer(part, innerExpr) {
+  var str = part.def.type + '(';
+  var parameters = _.map(part.params, (value, index) => {
+    var paramType = part.def.params[index];
+    if (paramType.type === 'time') {
+      if (value === 'auto') {
+        value = '$interval';
+      }
+    }
+    if (paramType.quote === 'single') {
+      return "'" + value + "'";
+    } else if (paramType.quote === 'double') {
+      return '"' + value + '"';
+    }
+
+    return value;
+  });
+
+  if (innerExpr) {
+    parameters.unshift(innerExpr);
+  }
+  return str + parameters.join(', ') + ')';
+}
+
+
+export function suffixRenderer(part, innerExpr) {
+  return innerExpr + ' ' + part.params[0];
+}
+
+export function identityRenderer(part, innerExpr) {
+  return part.params[0];
+}
+
+export function quotedIdentityRenderer(part, innerExpr) {
+  return '"' + part.params[0] + '"';
+}
+
+

+ 183 - 0
public/app/core/components/query_part/query_part_editor.ts

@@ -0,0 +1,183 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import $ from 'jquery';
+import coreModule from 'app/core/core_module';
+
+var template = `
+<div class="tight-form-func-controls">
+  <span class="pointer fa fa-remove" ng-click="removeActionInternal()"></span>
+</div>
+
+<a ng-click="toggleControls()" class="query-part-name">{{part.def.type}}</a>
+<span>(</span><span class="query-part-parameters"></span><span>)</span>
+`;
+
+  /** @ngInject */
+export function queryPartEditorDirective($compile, templateSrv) {
+
+  var paramTemplate = '<input type="text" style="display:none"' +
+    ' class="input-mini tight-form-func-param"></input>';
+  return {
+    restrict: 'E',
+    template: template,
+    scope: {
+      part: "=",
+      removeAction: "&",
+      partUpdated: "&",
+      getOptions: "&",
+    },
+    link: function postLink($scope, elem) {
+      var part = $scope.part;
+      var partDef = part.def;
+      var $paramsContainer = elem.find('.query-part-parameters');
+      var $controlsContainer = elem.find('.tight-form-func-controls');
+
+      function clickFuncParam(paramIndex) {
+        /*jshint validthis:true */
+        var $link = $(this);
+        var $input = $link.next();
+
+        $input.val(part.params[paramIndex]);
+        $input.css('width', ($link.width() + 16) + 'px');
+
+        $link.hide();
+        $input.show();
+        $input.focus();
+        $input.select();
+
+        var typeahead = $input.data('typeahead');
+        if (typeahead) {
+          $input.val('');
+          typeahead.lookup();
+        }
+      }
+
+      function inputBlur(paramIndex) {
+        /*jshint validthis:true */
+        var $input = $(this);
+        var $link = $input.prev();
+        var newValue = $input.val();
+
+        if (newValue !== '' || part.def.params[paramIndex].optional) {
+          $link.html(templateSrv.highlightVariablesAsHtml(newValue));
+
+          part.updateParam($input.val(), paramIndex);
+          $scope.$apply($scope.partUpdated);
+        }
+
+        $input.hide();
+        $link.show();
+      }
+
+      function inputKeyPress(paramIndex, e) {
+        /*jshint validthis:true */
+        if (e.which === 13) {
+          inputBlur.call(this, paramIndex);
+        }
+      }
+
+      function inputKeyDown() {
+        /*jshint validthis:true */
+        this.style.width = (3 + this.value.length) * 8 + 'px';
+      }
+
+      function addTypeahead($input, param, paramIndex) {
+        if (!param.options && !param.dynamicLookup) {
+          return;
+        }
+
+        var typeaheadSource = function (query, callback) {
+          if (param.options) { return param.options; }
+
+          $scope.$apply(function() {
+            $scope.getOptions().then(function(result) {
+              var dynamicOptions = _.map(result, function(op) { return op.value; });
+              callback(dynamicOptions);
+            });
+          });
+        };
+
+        $input.attr('data-provide', 'typeahead');
+        var options = param.options;
+        if (param.type === 'int') {
+          options = _.map(options, function(val) { return val.toString(); });
+        }
+
+        $input.typeahead({
+          source: typeaheadSource,
+          minLength: 0,
+          items: 1000,
+          updater: function (value) {
+            setTimeout(function() {
+              inputBlur.call($input[0], paramIndex);
+            }, 0);
+            return value;
+          }
+        });
+
+        var typeahead = $input.data('typeahead');
+        typeahead.lookup = function () {
+          this.query = this.$element.val() || '';
+          var items = this.source(this.query, $.proxy(this.process, this));
+          return items ? this.process(items) : items;
+        };
+      }
+
+      $scope.toggleControls = function() {
+        var targetDiv = elem.closest('.tight-form');
+
+        if (elem.hasClass('show-function-controls')) {
+          elem.removeClass('show-function-controls');
+          targetDiv.removeClass('has-open-function');
+          $controlsContainer.hide();
+          return;
+        }
+
+        elem.addClass('show-function-controls');
+        targetDiv.addClass('has-open-function');
+        $controlsContainer.show();
+      };
+
+      $scope.removeActionInternal = function() {
+        $scope.toggleControls();
+        $scope.removeAction();
+      };
+
+      function addElementsAndCompile() {
+        _.each(partDef.params, function(param, index) {
+          if (param.optional && part.params.length <= index) {
+            return;
+          }
+
+          if (index > 0) {
+            $('<span>, </span>').appendTo($paramsContainer);
+          }
+
+          var paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
+          var $paramLink = $('<a class="graphite-func-param-link pointer">' + paramValue + '</a>');
+          var $input = $(paramTemplate);
+
+          $paramLink.appendTo($paramsContainer);
+          $input.appendTo($paramsContainer);
+
+          $input.blur(_.partial(inputBlur, index));
+          $input.keyup(inputKeyDown);
+          $input.keypress(_.partial(inputKeyPress, index));
+          $paramLink.click(_.partial(clickFuncParam, index));
+
+          addTypeahead($input, param, index);
+        });
+      }
+
+      function relink() {
+        $paramsContainer.empty();
+        addElementsAndCompile();
+      }
+
+      relink();
+    }
+  };
+}
+
+coreModule.directive('queryPartEditor', queryPartEditorDirective);

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

@@ -33,6 +33,7 @@ import {Emitter} from './utils/emitter';
 import {layoutSelector} from './components/layout_selector/layout_selector';
 import {switchDirective} from './components/switch';
 import {dashboardSelector} from './components/dashboard_selector';
+import {queryPartEditorDirective} from './components/query_part/query_part_editor';
 import 'app/core/controllers/all';
 import 'app/core/services/all';
 import 'app/core/routes/routes';
@@ -56,4 +57,5 @@ export {
   Emitter,
   appEvents,
   dashboardSelector,
+  queryPartEditorDirective,
 };

+ 13 - 10
public/app/features/dashboard/viewStateSrv.js

@@ -120,25 +120,28 @@ function (angular, _, $) {
       if (this.panelScopes.length === 0) { return; }
 
       if (this.dashboard.meta.fullscreen) {
-        if (this.fullscreenPanel) {
-          this.leaveFullscreen(false);
-        }
         var panelScope = this.getPanelScope(this.state.panelId);
-        // panel could be about to be created/added and scope does
-        // not exist yet
         if (!panelScope) {
           return;
         }
 
+        if (this.fullscreenPanel) {
+          // if already fullscreen
+          if (this.fullscreenPanel === panelScope) {
+            return;
+          } else {
+            this.leaveFullscreen(false);
+          }
+        }
+
         if (!panelScope.ctrl.editModeInitiated) {
           panelScope.ctrl.initEditMode();
         }
 
-        this.enterFullscreen(panelScope);
-        return;
-      }
-
-      if (this.fullscreenPanel) {
+        if (!panelScope.ctrl.fullscreen) {
+          this.enterFullscreen(panelScope);
+        }
+      } else if (this.fullscreenPanel) {
         this.leaveFullscreen(true);
       }
     };

+ 1 - 1
public/app/features/org/partials/profile.html

@@ -51,7 +51,7 @@
 						<span class="btn btn-primary btn-mini" ng-show="org.orgId === contextSrv.user.orgId">
 							Current
 						</span>
-						<a ng-click="setUsingOrg(org)" class="btn btn-inverse btn-mini" ng-show="org.orgId !== contextSrv.user.orgId">
+						<a ng-click="ctrl.setUsingOrg(org)" class="btn btn-inverse btn-mini" ng-show="org.orgId !== contextSrv.user.orgId">
 							Select
 						</a>
 					</td>

+ 2 - 2
public/app/features/panel/panel_ctrl.ts

@@ -152,8 +152,8 @@ export class PanelCtrl {
   calculatePanelHeight() {
     if (this.fullscreen) {
       var docHeight = $(window).height();
-      var editHeight = Math.floor(docHeight * 0.3);
-      var fullscreenHeight = Math.floor(docHeight * 0.7);
+      var editHeight = Math.floor(docHeight * 0.4);
+      var fullscreenHeight = Math.floor(docHeight * 0.6);
       this.containerHeight = this.editMode ? editHeight : fullscreenHeight;
     } else {
       this.containerHeight = this.panel.height || this.row.height;

+ 4 - 4
public/app/plugins/datasource/influxdb/partials/query.editor.html

@@ -35,13 +35,13 @@
 			</div>
 
 			<div class="gf-form" ng-repeat="part in selectParts">
-				<influx-query-part-editor
+				<query-part-editor
 														class="gf-form-label query-part"
 														part="part"
 														remove-action="ctrl.removeSelectPart(selectParts, part)"
 														part-updated="ctrl.selectPartUpdated(selectParts, part)"
 														get-options="ctrl.getPartOptions(part)">
-				</influx-query-part-editor>
+				</query-part-editor>
 			</div>
 
 			<div class="gf-form">
@@ -62,12 +62,12 @@
 					<span>GROUP BY</span>
 				</label>
 
-				<influx-query-part-editor
+				<query-part-editor
 								ng-repeat="part in ctrl.queryModel.groupByParts"
 								part="part"
 								class="gf-form-label query-part"
 								remove-action="ctrl.removeGroupByPart(part, $index)" part-updated="ctrl.refresh();" get-options="ctrl.getPartOptions(part)">
-				</influx-query-part-editor>
+				</query-part-editor>
 			</div>
 
 			<div class="gf-form">

+ 0 - 5
public/app/plugins/datasource/influxdb/partials/query_part.html

@@ -1,5 +0,0 @@
-<div class="tight-form-func-controls">
-	<span class="pointer fa fa-remove" ng-click="removeActionInternal()" ></span>
-</div>
-
-<a ng-click="toggleControls()" class="query-part-name">{{part.def.type}}</a><span>(</span><span class="query-part-parameters"></span><span>)</span>

+ 0 - 3
public/app/plugins/datasource/influxdb/query_ctrl.ts

@@ -1,8 +1,5 @@
 ///<reference path="../../../headers/common.d.ts" />
 
-import './query_part_editor';
-import './query_part_editor';
-
 import angular from 'angular';
 import _ from 'lodash';
 import InfluxQueryBuilder from './query_builder';

+ 46 - 148
public/app/plugins/datasource/influxdb/query_part.ts

@@ -1,6 +1,14 @@
 ///<reference path="../../../headers/common.d.ts" />
 
 import _ from 'lodash';
+import {
+  QueryPartDef,
+  QueryPart,
+  functionRenderer,
+  suffixRenderer,
+  identityRenderer,
+  quotedIdentityRenderer,
+} from 'app/core/components/query_part/query_part';
 
 var index = [];
 var categories = {
@@ -12,71 +20,26 @@ var categories = {
   Fields: [],
 };
 
-var groupByTimeFunctions = [];
-
-class QueryPartDef {
-  type: string;
-  params: any[];
-  defaultParams: any[];
-  renderer: any;
-  category: any;
-  addStrategy: any;
-
-  constructor(options: any) {
-    this.type = options.type;
-    this.params = options.params;
-    this.defaultParams = options.defaultParams;
-    this.renderer = options.renderer;
-    this.category = options.category;
-    this.addStrategy = options.addStrategy;
-  }
-
-  static register(options: any) {
-    index[options.type] = new QueryPartDef(options);
-    options.category.push(index[options.type]);
+function createPart(part): any {
+  var def = index[part.type];
+  if (!def) {
+    throw {message: 'Could not find query part ' + part.type};
   }
-}
 
-function functionRenderer(part, innerExpr) {
-  var str = part.def.type + '(';
-  var parameters = _.map(part.params, (value, index) => {
-    var paramType = part.def.params[index];
-    if (paramType.type === 'time') {
-      if (value === 'auto') {
-        value = '$interval';
-      }
-    }
-    if (paramType.quote === 'single') {
-      return "'" + value + "'";
-    } else if (paramType.quote === 'double') {
-      return '"' + value + '"';
-    }
-
-    return value;
-  });
+  return new QueryPart(part, def);
+};
 
-  if (innerExpr) {
-    parameters.unshift(innerExpr);
-  }
-  return str + parameters.join(', ') + ')';
+function register(options: any) {
+  index[options.type] = new QueryPartDef(options);
+  options.category.push(index[options.type]);
 }
 
+var groupByTimeFunctions = [];
+
 function aliasRenderer(part, innerExpr) {
   return innerExpr + ' AS ' + '"' + part.params[0] + '"';
 }
 
-function suffixRenderer(part, innerExpr) {
-  return innerExpr + ' ' + part.params[0];
-}
-
-function identityRenderer(part, innerExpr) {
-  return part.params[0];
-}
-
-function quotedIdentityRenderer(part, innerExpr) {
-  return '"' + part.params[0] + '"';
-}
-
 function fieldRenderer(part, innerExpr) {
   if (part.params[0] === '*')  {
     return '*';
@@ -149,13 +112,13 @@ function addAliasStrategy(selectParts, partModel) {
 function addFieldStrategy(selectParts, partModel, query) {
   // copy all parts
   var parts = _.map(selectParts, function(part: any) {
-    return new QueryPart({type: part.def.type, params: _.clone(part.params)});
+    return createPart({type: part.def.type, params: _.clone(part.params)});
   });
 
   query.selectModels.push(parts);
 }
 
-QueryPartDef.register({
+register({
   type: 'field',
   addStrategy: addFieldStrategy,
   category: categories.Fields,
@@ -165,7 +128,7 @@ QueryPartDef.register({
 });
 
 // Aggregations
-QueryPartDef.register({
+register({
   type: 'count',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Aggregations,
@@ -174,7 +137,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'distinct',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Aggregations,
@@ -183,7 +146,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'integral',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Aggregations,
@@ -192,7 +155,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'mean',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Aggregations,
@@ -201,7 +164,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'median',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Aggregations,
@@ -210,7 +173,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'sum',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Aggregations,
@@ -221,7 +184,7 @@ QueryPartDef.register({
 
 // transformations
 
-QueryPartDef.register({
+register({
   type: 'derivative',
   addStrategy: addTransformationStrategy,
   category: categories.Transformations,
@@ -230,7 +193,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'spread',
   addStrategy: addTransformationStrategy,
   category: categories.Transformations,
@@ -239,7 +202,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'non_negative_derivative',
   addStrategy: addTransformationStrategy,
   category: categories.Transformations,
@@ -248,7 +211,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'difference',
   addStrategy: addTransformationStrategy,
   category: categories.Transformations,
@@ -257,7 +220,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'moving_average',
   addStrategy: addTransformationStrategy,
   category: categories.Transformations,
@@ -266,7 +229,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'stddev',
   addStrategy: addTransformationStrategy,
   category: categories.Transformations,
@@ -275,7 +238,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'time',
   category: groupByTimeFunctions,
   params: [{ name: "interval", type: "time", options: ['auto', '1s', '10s', '1m', '5m', '10m', '15m', '1h'] }],
@@ -283,7 +246,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'fill',
   category: groupByTimeFunctions,
   params: [{ name: "fill", type: "string", options: ['none', 'null', '0', 'previous'] }],
@@ -292,7 +255,7 @@ QueryPartDef.register({
 });
 
 // Selectors
-QueryPartDef.register({
+register({
   type: 'bottom',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Selectors,
@@ -301,7 +264,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'first',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Selectors,
@@ -310,7 +273,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'last',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Selectors,
@@ -319,7 +282,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'max',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Selectors,
@@ -328,7 +291,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'min',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Selectors,
@@ -337,7 +300,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'percentile',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Selectors,
@@ -346,7 +309,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'top',
   addStrategy: replaceAggregationAddStrategy,
   category: categories.Selectors,
@@ -355,7 +318,7 @@ QueryPartDef.register({
   renderer: functionRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'tag',
   category: groupByTimeFunctions,
   params: [{name: 'tag', type: 'string', dynamicLookup: true}],
@@ -363,7 +326,7 @@ QueryPartDef.register({
   renderer: fieldRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'math',
   addStrategy: addMathStrategy,
   category: categories.Math,
@@ -372,7 +335,7 @@ QueryPartDef.register({
   renderer: suffixRenderer,
 });
 
-QueryPartDef.register({
+register({
   type: 'alias',
   addStrategy: addAliasStrategy,
   category: categories.Aliasing,
@@ -382,74 +345,9 @@ QueryPartDef.register({
   renderer: aliasRenderer,
 });
 
-class QueryPart {
-  part: any;
-  def: QueryPartDef;
-  params: any[];
-  text: string;
-
-  constructor(part: any) {
-    this.part = part;
-    this.def = index[part.type];
-    if (!this.def) {
-      throw {message: 'Could not find query part ' + part.type};
-    }
-
-    part.params = part.params || _.clone(this.def.defaultParams);
-    this.params = part.params;
-    this.updateText();
-  }
-
-  render(innerExpr: string) {
-    return this.def.renderer(this, innerExpr);
-  }
-
-  hasMultipleParamsInString (strValue, index) {
-    if (strValue.indexOf(',') === -1) {
-      return false;
-    }
-
-    return this.def.params[index + 1] && this.def.params[index + 1].optional;
-  }
-
-  updateParam (strValue, index) {
-    // handle optional parameters
-    // if string contains ',' and next param is optional, split and update both
-    if (this.hasMultipleParamsInString(strValue, index)) {
-      _.each(strValue.split(','), function(partVal: string, idx) {
-        this.updateParam(partVal.trim(), idx);
-      }, this);
-      return;
-    }
-
-    if (strValue === '' && this.def.params[index].optional) {
-      this.params.splice(index, 1);
-    } else {
-      this.params[index] = strValue;
-    }
-
-    this.part.params = this.params;
-    this.updateText();
-  }
-
-  updateText() {
-    if (this.params.length === 0) {
-      this.text = this.def.type + '()';
-      return;
-    }
-
-    var text = this.def.type + '(';
-    text += this.params.join(', ');
-    text += ')';
-    this.text = text;
-  }
-}
 
 export default {
-  create: function(part): any {
-    return new QueryPart(part);
-  },
-
+  create: createPart,
   getCategories: function() {
     return categories;
   }

+ 0 - 178
public/app/plugins/datasource/influxdb/query_part_editor.js

@@ -1,178 +0,0 @@
-define([
-  'angular',
-  'lodash',
-  'jquery',
-],
-function (angular, _, $) {
-  'use strict';
-
-  angular
-    .module('grafana.directives')
-    .directive('influxQueryPartEditor', function($compile, templateSrv) {
-
-      var paramTemplate = '<input type="text" style="display:none"' +
-                          ' class="input-mini tight-form-func-param"></input>';
-      return {
-        restrict: 'E',
-        templateUrl: 'public/app/plugins/datasource/influxdb/partials/query_part.html',
-        scope: {
-          part: "=",
-          removeAction: "&",
-          partUpdated: "&",
-          getOptions: "&",
-        },
-        link: function postLink($scope, elem) {
-          var part = $scope.part;
-          var partDef = part.def;
-          var $paramsContainer = elem.find('.query-part-parameters');
-          var $controlsContainer = elem.find('.tight-form-func-controls');
-
-          function clickFuncParam(paramIndex) {
-            /*jshint validthis:true */
-            var $link = $(this);
-            var $input = $link.next();
-
-            $input.val(part.params[paramIndex]);
-            $input.css('width', ($link.width() + 16) + 'px');
-
-            $link.hide();
-            $input.show();
-            $input.focus();
-            $input.select();
-
-            var typeahead = $input.data('typeahead');
-            if (typeahead) {
-              $input.val('');
-              typeahead.lookup();
-            }
-          }
-
-          function inputBlur(paramIndex) {
-            /*jshint validthis:true */
-            var $input = $(this);
-            var $link = $input.prev();
-            var newValue = $input.val();
-
-            if (newValue !== '' || part.def.params[paramIndex].optional) {
-              $link.html(templateSrv.highlightVariablesAsHtml(newValue));
-
-              part.updateParam($input.val(), paramIndex);
-              $scope.$apply($scope.partUpdated);
-            }
-
-            $input.hide();
-            $link.show();
-          }
-
-          function inputKeyPress(paramIndex, e) {
-            /*jshint validthis:true */
-            if(e.which === 13) {
-              inputBlur.call(this, paramIndex);
-            }
-          }
-
-          function inputKeyDown() {
-            /*jshint validthis:true */
-            this.style.width = (3 + this.value.length) * 8 + 'px';
-          }
-
-          function addTypeahead($input, param, paramIndex) {
-            if (!param.options && !param.dynamicLookup) {
-              return;
-            }
-
-            var typeaheadSource = function (query, callback) {
-              if (param.options) { return param.options; }
-
-              $scope.$apply(function() {
-                $scope.getOptions().then(function(result) {
-                  var dynamicOptions = _.map(result, function(op) { return op.value; });
-                  callback(dynamicOptions);
-                });
-              });
-            };
-
-            $input.attr('data-provide', 'typeahead');
-            var options = param.options;
-            if (param.type === 'int') {
-              options = _.map(options, function(val) { return val.toString(); });
-            }
-
-            $input.typeahead({
-              source: typeaheadSource,
-              minLength: 0,
-              items: 1000,
-              updater: function (value) {
-                setTimeout(function() {
-                  inputBlur.call($input[0], paramIndex);
-                }, 0);
-                return value;
-              }
-            });
-
-            var typeahead = $input.data('typeahead');
-            typeahead.lookup = function () {
-              this.query = this.$element.val() || '';
-              var items = this.source(this.query, $.proxy(this.process, this));
-              return items ? this.process(items) : items;
-            };
-          }
-
-          $scope.toggleControls = function() {
-            var targetDiv = elem.closest('.tight-form');
-
-            if (elem.hasClass('show-function-controls')) {
-              elem.removeClass('show-function-controls');
-              targetDiv.removeClass('has-open-function');
-              $controlsContainer.hide();
-              return;
-            }
-
-            elem.addClass('show-function-controls');
-            targetDiv.addClass('has-open-function');
-            $controlsContainer.show();
-          };
-
-          $scope.removeActionInternal = function() {
-            $scope.toggleControls();
-            $scope.removeAction();
-          };
-
-          function addElementsAndCompile() {
-            _.each(partDef.params, function(param, index) {
-              if (param.optional && part.params.length <= index) {
-                return;
-              }
-
-              if (index > 0) {
-                $('<span>, </span>').appendTo($paramsContainer);
-              }
-
-              var paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
-              var $paramLink = $('<a class="graphite-func-param-link pointer">' + paramValue + '</a>');
-              var $input = $(paramTemplate);
-
-              $paramLink.appendTo($paramsContainer);
-              $input.appendTo($paramsContainer);
-
-              $input.blur(_.partial(inputBlur, index));
-              $input.keyup(inputKeyDown);
-              $input.keypress(_.partial(inputKeyPress, index));
-              $paramLink.click(_.partial(clickFuncParam, index));
-
-              addTypeahead($input, param, index);
-            });
-          }
-
-          function relink() {
-            $paramsContainer.empty();
-            addElementsAndCompile();
-          }
-
-          relink();
-        }
-      };
-
-    });
-
-});

+ 1 - 4
public/app/plugins/datasource/opentsdb/datasource.js

@@ -403,10 +403,7 @@ function (angular, _, dateMath) {
         } else {
           return _.findIndex(options.targets, function(target) {
             if (target.filters && target.filters.length > 0) {
-              return target.metric === metricData.metric &&
-              _.all(target.filters, function(filter) {
-                return filter.tagk === interpolatedTagValue === "*";
-              });
+              return target.metric === metricData.metric;
             } else {
               return target.metric === metricData.metric &&
               _.all(target.tags, function(tagV, tagK) {

+ 135 - 0
public/app/plugins/panel/graph/alert_handle.ts

@@ -0,0 +1,135 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import 'jquery.flot';
+import $ from 'jquery';
+import _ from 'lodash';
+
+export class AlertHandleManager {
+  plot: any;
+  placeholder: any;
+  height: any;
+  alert: any;
+
+  constructor(private panelCtrl) {
+    this.alert = panelCtrl.panel.alert;
+  }
+
+  getHandleInnerHtml(type, op, value) {
+    if (op === '>') { op = '&gt;'; }
+    if (op === '<') { op = '&lt;'; }
+
+    return `
+    <div class="alert-handle-line">
+    </div>
+    <div class="alert-handle">
+    <i class="icon-gf icon-gf-${type} alert-icon-${type}"></i>
+    ${op} ${value}
+    </div>`;
+  }
+
+  getFullHandleHtml(type, op, value) {
+    var innerTemplate = this.getHandleInnerHtml(type, op, value);
+    return `
+    <div class="alert-handle-wrapper alert-handle-wrapper--${type}">
+    ${innerTemplate}
+    </div>
+    `;
+  }
+
+  setupDragging(handleElem, levelModel) {
+    var isMoving = false;
+    var lastY = null;
+    var posTop;
+    var plot = this.plot;
+    var panelCtrl = this.panelCtrl;
+
+    function dragging(evt) {
+      if (lastY === null) {
+        lastY = evt.clientY;
+      } else {
+        var diff = evt.clientY - lastY;
+        posTop = posTop + diff;
+        lastY = evt.clientY;
+        handleElem.css({top: posTop + diff});
+      }
+    }
+
+    function stopped() {
+      isMoving = false;
+      // calculate graph level
+      var graphLevel = plot.c2p({left: 0, top: posTop}).y;
+      console.log('canvasPos:' + posTop + ' Graph level: ' + graphLevel);
+      graphLevel = parseInt(graphLevel.toFixed(0));
+      levelModel.level = graphLevel;
+      console.log(levelModel);
+
+      var levelCanvasPos = plot.p2c({x: 0, y: graphLevel});
+      console.log('canvas pos', levelCanvasPos);
+
+      console.log('stopped');
+      handleElem.off("mousemove", dragging);
+      handleElem.off("mouseup", dragging);
+
+      // trigger digest and render
+      panelCtrl.$scope.$apply(function() {
+        panelCtrl.render();
+      });
+    }
+
+    handleElem.bind('mousedown', function() {
+      isMoving = true;
+      lastY = null;
+      posTop = handleElem.position().top;
+      console.log('start pos', posTop);
+
+      handleElem.on("mousemove", dragging);
+      handleElem.on("mouseup", stopped);
+    });
+  }
+
+  cleanUp() {
+    if (this.placeholder) {
+      this.placeholder.find(".alert-handle-wrapper").remove();
+    }
+  }
+
+  renderHandle(type, model, defaultHandleTopPos) {
+    var handleElem = this.placeholder.find(`.alert-handle-wrapper--${type}`);
+    var level = model.level;
+    var levelStr = level;
+    var handleTopPos = 0;
+
+    // handle no value
+    if (!_.isNumber(level)) {
+      levelStr = '';
+      handleTopPos = defaultHandleTopPos;
+    } else {
+      var levelCanvasPos = this.plot.p2c({x: 0, y: level});
+      handleTopPos = Math.min(Math.max(levelCanvasPos.top, 0), this.height) - 6;
+    }
+
+    if (handleElem.length === 0) {
+      console.log('creating handle');
+      handleElem = $(this.getFullHandleHtml(type, model.op, levelStr));
+      this.placeholder.append(handleElem);
+      this.setupDragging(handleElem, model);
+    } else {
+      console.log('reusing handle!');
+      handleElem.html(this.getHandleInnerHtml(type, model.op, levelStr));
+    }
+
+    handleElem.toggleClass('alert-handle-wrapper--no-value', levelStr === '');
+    handleElem.css({top: handleTopPos});
+  }
+
+  draw(plot) {
+    this.plot = plot;
+    this.placeholder = plot.getPlaceholder();
+    this.height = plot.height();
+
+    this.renderHandle('critical', this.alert.critical, 10);
+    this.renderHandle('warn', this.alert.warn, this.height-30);
+  }
+
+}
+

+ 135 - 42
public/app/plugins/panel/graph/alert_tab_ctrl.ts

@@ -1,78 +1,171 @@
-///<reference path="../../../headers/common.d.ts" />
+ ///<reference path="../../../headers/common.d.ts" />
 
 import _ from 'lodash';
 import $ from 'jquery';
 import angular from 'angular';
 
+import {
+  QueryPartDef,
+  QueryPart,
+} from 'app/core/components/query_part/query_part';
+
+var alertQueryDef = new QueryPartDef({
+  type: 'query',
+  params: [
+    {name: "queryRefId", type: 'string', options: ['#A', '#B', '#C', '#D']},
+    {name: "from", type: "string", options: ['1s', '10s', '1m', '5m', '10m', '15m', '1h']},
+    {name: "to", type: "string", options: ['now']},
+  ],
+  defaultParams: ['#A', '5m', 'now', 'avg']
+});
+
 export class AlertTabCtrl {
   panel: any;
   panelCtrl: any;
-  alerting: any;
   metricTargets = [{ refId: '- select query -' } ];
-  operators = ['>', '<', '<=', '>='];
-  aggregators = ['avg', 'sum', 'min', 'max', 'median'];
+  schedulers = [{text: 'Grafana', value: 1}, {text: 'External', value: 0}];
+  transforms = [
+    {
+      text: 'Aggregation',
+      type: 'aggregation',
+    },
+    {
+      text: 'Linear Forecast',
+      type: 'forecast',
+    },
+  ];
+  aggregators = ['avg', 'sum', 'min', 'max', 'last'];
+  alert: any;
+  query: any;
+  queryParams: any;
+  transformDef: any;
+  levelOpList = [
+    {text: '>', value: '>'},
+    {text: '<', value: '<'},
+    {text: '=', value: '='},
+  ];
 
   defaultValues = {
-    aggregator: 'avg',
-    frequency: 10,
-    queryRange: 3600,
-    warnOperator: '>',
-    critOperator: '>',
-    queryRef: '- select query -'
+    frequency: '60s',
+    notify: [],
+    enabled: false,
+    scheduler: 1,
+    warn: { op: '>', level: undefined },
+    critical: { op: '>', level: undefined },
+    query: {
+      refId: 'A',
+      from: '5m',
+      to: 'now',
+    },
+    transform: {
+      type: 'aggregation',
+      method: 'avg'
+    }
   };
 
   /** @ngInject */
   constructor($scope, private $timeout) {
-    $scope.alertTab = this; //HACK ATTACK!
     this.panelCtrl = $scope.ctrl;
     this.panel = this.panelCtrl.panel;
+    $scope.ctrl = this;
+
+    this.metricTargets = this.panel.targets.map(val => val);
+    this.initAlertModel();
+
+    // set panel alert edit mode
+    $scope.$on("$destroy", () => {
+      this.panelCtrl.editingAlert = false;
+      this.panelCtrl.render();
+    });
+  }
+
+  initAlertModel() {
+    if (!this.panel.alert) {
+      return;
+    }
 
-    _.defaults(this.panel.alerting, this.defaultValues);
+    this.alert = this.panel.alert;
+
+    // set defaults
+    _.defaults(this.alert, this.defaultValues);
 
     var defaultName = (this.panelCtrl.dashboard.title + ' ' + this.panel.title + ' alert');
-    this.panel.alerting.name = this.panel.alerting.name || defaultName;
+    this.alert.name = this.alert.name || defaultName;
+    this.alert.description = this.alert.description || defaultName;
 
-    this.panel.targets.map(target => {
-      this.metricTargets.push(target);
-    });
-    this.panel.alerting.queryRef = this.panel.alerting.queryRef || this.metricTargets[0].refId;
+    // great temp working model
+    this.queryParams = {
+      params: [
+        this.alert.query.refId,
+        this.alert.query.from,
+        this.alert.query.to
+      ]
+    };
 
+    // init the query part components model
+    this.query = new QueryPart(this.queryParams, alertQueryDef);
     this.convertThresholdsToAlertThresholds();
+    this.transformDef = _.findWhere(this.transforms, {type: this.alert.transform.type});
+
+    this.panelCtrl.editingAlert = true;
+    this.panelCtrl.render();
   }
 
-  convertThresholdsToAlertThresholds() {
-    if (this.panel.grid
-        && this.panel.grid.threshold1
-        && this.panel.alerting.warnLevel === undefined
-       ) {
-      this.panel.alerting.warnOperator = '>';
-      this.panel.alerting.warnLevel = this.panel.grid.threshold1;
-    }
+  queryUpdated() {
+    this.alert.query = {
+      refId: this.query.params[0],
+      from: this.query.params[1],
+      to: this.query.params[2],
+    };
+  }
+
+  transformChanged() {
+    // clear model
+    this.alert.transform = {type: this.alert.transform.type};
+    this.transformDef = _.findWhere(this.transforms, {type: this.alert.transform.type});
 
-    if (this.panel.grid
-        && this.panel.grid.threshold2
-        && this.panel.alerting.critLevel === undefined
-       ) {
-      this.panel.alerting.critOperator = '>';
-      this.panel.alerting.critLevel = this.panel.grid.threshold2;
+    switch (this.alert.transform.type) {
+      case 'aggregation':  {
+        this.alert.transform.method = 'avg';
+        break;
+      }
+      case "forecast": {
+        this.alert.transform.timespan = '7d';
+        break;
+      }
     }
   }
 
-  markAsDeleted() {
-    if (this.panel.alerting) {
-      this.panel.alerting = this.defaultValues;
-    }
+  convertThresholdsToAlertThresholds() {
+    // if (this.panel.grid
+    //     && this.panel.grid.threshold1
+    //     && this.alert.warnLevel === undefined
+    //    ) {
+    //   this.alert.warning.op = '>';
+    //   this.alert.warning.level = this.panel.grid.threshold1;
+    // }
+    //
+    // if (this.panel.grid
+    //     && this.panel.grid.threshold2
+    //     && this.alert.critical.level === undefined
+    //    ) {
+    //   this.alert.critical.op = '>';
+    //   this.alert.critical.level = this.panel.grid.threshold2;
+    // }
   }
 
-  thresholdsUpdated() {
-    if (this.panel.alerting.warnLevel) {
-      this.panel.grid.threshold1 = parseInt(this.panel.alerting.warnLevel);
-    }
+  delete() {
+    delete this.panel.alert;
+    this.panelCtrl.editingAlert = false;
+    this.panelCtrl.render();
+  }
 
-    if (this.panel.alerting.critLevel) {
-      this.panel.grid.threshold2 = parseInt(this.panel.alerting.critLevel);
-    }
+  enable() {
+    this.panel.alert = {};
+    this.initAlertModel();
+  }
 
+  levelsUpdated() {
     this.panelCtrl.render();
   }
 }

+ 67 - 1
public/app/plugins/panel/graph/graph.js

@@ -5,6 +5,7 @@ define([
   'lodash',
   'app/core/utils/kbn',
   './graph_tooltip',
+  './alert_handle',
   'jquery.flot',
   'jquery.flot.selection',
   'jquery.flot.time',
@@ -14,13 +15,16 @@ define([
   'jquery.flot.crosshair',
   './jquery.flot.events',
 ],
-function (angular, $, moment, _, kbn, GraphTooltip) {
+function (angular, $, moment, _, kbn, GraphTooltip, AlertHandle) {
   'use strict';
 
   var module = angular.module('grafana.directives');
   var labelWidthCache = {};
   var panelWidthCache = {};
 
+  // systemjs export
+  var AlertHandleManager = AlertHandle.AlertHandleManager;
+
   module.directive('grafanaGraph', function($rootScope, timeSrv) {
     return {
       restrict: 'A',
@@ -34,6 +38,7 @@ function (angular, $, moment, _, kbn, GraphTooltip) {
         var legendSideLastValue = null;
         var rootScope = scope.$root;
         var panelWidth = 0;
+        var alertHandles;
 
         rootScope.onAppEvent('setCrosshair', function(event, info) {
           // do not need to to this if event is from this panel
@@ -161,6 +166,10 @@ function (angular, $, moment, _, kbn, GraphTooltip) {
 
             rightLabel[0].style.marginTop = (getLabelWidth(panel.yaxes[1].label, rightLabel) / 2) + 'px';
           }
+
+          if (alertHandles) {
+            alertHandles.draw(plot);
+          }
         }
 
         function processOffsetHook(plot, gridMargin) {
@@ -181,6 +190,18 @@ function (angular, $, moment, _, kbn, GraphTooltip) {
             return;
           }
 
+          // give space to alert editing
+          if (ctrl.editingAlert) {
+            if (!alertHandles) {
+              elem.css('margin-right', '220px');
+              alertHandles = new AlertHandleManager(ctrl);
+            }
+          } else if (alertHandles) {
+            elem.css('margin-right', '0');
+            alertHandles.cleanUp();
+            alertHandles = null;
+          }
+
           var stack = panel.stack ? true : null;
 
           // Populate element
@@ -259,6 +280,7 @@ function (angular, $, moment, _, kbn, GraphTooltip) {
 
           function callPlot(incrementRenderCounter) {
             try {
+              console.log('rendering');
               $.plot(elem, sortedSeries, options);
             } catch (e) {
               console.log('flotcharts error', e);
@@ -311,6 +333,50 @@ function (angular, $, moment, _, kbn, GraphTooltip) {
         }
 
         function addGridThresholds(options, panel) {
+          if (panel.alert) {
+            var crit = panel.alert.critical;
+            var warn = panel.alert.warn;
+            var critEdge = Infinity;
+            var warnEdge = crit.level;
+
+            if (_.isNumber(crit.level)) {
+              if (crit.op === '<') {
+                critEdge = -Infinity;
+              }
+
+              // fill
+              options.grid.markings.push({
+                yaxis: {from: crit.level, to: critEdge},
+                color: 'rgba(234, 112, 112, 0.10)',
+              });
+
+              // line
+              options.grid.markings.push({
+                yaxis: {from: crit.level, to: crit.level},
+                color: '#ed2e18'
+              });
+            }
+
+            if (_.isNumber(warn.level)) {
+              // if (warn.op === '<') {
+              // }
+
+              // fill
+              options.grid.markings.push({
+                yaxis: {from: warn.level, to: warnEdge},
+                color: 'rgba(216, 200, 27, 0.10)',
+              });
+
+              // line
+              options.grid.markings.push({
+                yaxis: {from: warn.level, to: warn.level},
+                color: '#F79520'
+              });
+            }
+
+            return;
+          }
+
           if (_.isNumber(panel.grid.threshold1)) {
             var limit1 = panel.grid.thresholdLine ? panel.grid.threshold1 : (panel.grid.threshold2 || null);
             options.grid.markings.push({

+ 90 - 49
public/app/plugins/panel/graph/partials/tab_alerting.html

@@ -1,63 +1,99 @@
-<div class="editor-row">
-  <div class="gf-form-group section" >
-    <h5 class="section-heading">Query</h5>
-    <div class="gf-form" style="margin-bottom: 2rem">
-      <span class="gf-form-label width-9">Query to watch</span>
-      <div class="gf-form-select-wrapper max-width-12">
-        <select class="gf-form-input"
-          ng-model="ctrl.panel.alerting.queryRef"
-          ng-options="target.refId as target.refId for target in alertTab.metricTargets"></select>
-      </div>
-    </div>
 
-    <h5 class="section-heading">Thresholds</h5>
-    <div class="gf-form">
-      <span class="gf-form-label width-9">
-        <i class="icon-gf icon-gf-warn alert-icon-warn"></i>
-        Warn level
-      </span>
-      <div class="gf-form-select-wrapper max-width-10">
-				<select class="gf-form-input" ng-model="ctrl.panel.alerting.warnOperator" ng-options="oper as oper for oper in alertTab.operators"></select>
+<div ng-if="ctrl.panel.alert">
+  <div class="editor-row">
+    <div class="gf-form-group section" >
+      <h5 class="section-heading">Alert Query</h5>
+      <div class="gf-form-inline">
+        <div class="gf-form">
+          <query-part-editor
+             class="gf-form-label query-part"
+             part="ctrl.query"
+             part-updated="ctrl.queryUpdated()">
+          </query-part-editor>
+        </div>
+        <div class="gf-form">
+          <span class="gf-form-label">Transform using</span>
+          <div class="gf-form-select-wrapper">
+            <select   class="gf-form-input"
+                      ng-model="ctrl.alert.transform.type"
+                      ng-options="f.type as f.text for f in ctrl.transforms"
+                      ng-change="ctrl.transformChanged()"
+                      >
+            </select>
+          </div>
+        </div>
+        <div class="gf-form" ng-if="ctrl.transformDef.type === 'aggregation'">
+          <span class="gf-form-label">Method</span>
+          <div class="gf-form-select-wrapper">
+            <select   class="gf-form-input"
+                      ng-model="ctrl.alert.transform.method"
+                      ng-options="f for f in ctrl.aggregators">
+            </select>
+          </div>
+        </div>
+        <div class="gf-form" ng-if="ctrl.transformDef.type === 'forecast'">
+          <span class="gf-form-label">Timespan</span>
+          <input class="gf-form-input max-width-5" type="text" ng-model="ctrl.alert.transform.timespan" ng-change="ctrl.ruleUpdated()"></input>
+        </div>
       </div>
-      <input class="gf-form-input max-width-7" type="number" ng-model="ctrl.panel.alerting.warnLevel" ng-change="alertTab.thresholdsUpdated()"></input>
     </div>
-    <div class="gf-form">
-      <span class="gf-form-label width-9">
-        <i class="icon-gf icon-gf-critical alert-icon-critical"></i>
-        Critical level
-      </span>
-      <div class="gf-form-select-wrapper max-width-10">
-        <select class="gf-form-input" ng-model="ctrl.panel.alerting.critOperator" ng-options="oper as oper for oper in alertTab.operators"></select>
+
+    <div class="gf-form-group section">
+      <h5 class="section-heading">Levels</h5>
+      <div class="gf-form-inline">
+        <div class="gf-form">
+          <span class="gf-form-label">
+            <i class="icon-gf icon-gf-warn alert-icon-warn"></i>
+            Warn if
+          </span>
+          <metric-segment-model property="ctrl.alert.warn.op" options="ctrl.levelOpList" custom="false" css-class="query-segment-operator"></metric-segment-model>
+          <input class="gf-form-input max-width-7" type="number" ng-model="ctrl.alert.warn.level" ng-change="ctrl.levelsUpdated()"></input>
+        </div>
+        <div class="gf-form">
+          <span class="gf-form-label">
+            <i class="icon-gf icon-gf-warn alert-icon-critical"></i>
+            Critcal if
+          </span>
+          <metric-segment-model property="ctrl.alert.critical.op" options="ctrl.levelOpList" custom="false" css-class="query-segment-operator"></metric-segment-model>
+          <input class="gf-form-input max-width-7" type="number" ng-model="ctrl.alert.critical.level" ng-change="ctrl.levelsUpdated()"></input>
+        </div>
       </div>
-      <input class="gf-form-input max-width-7" type="number" ng-model="ctrl.panel.alerting.critLevel" ng-change="alertTab.thresholdsUpdated()"></input>
     </div>
   </div>
 
-  <div class="gf-form-group section">
-    <h5 class="section-heading">Aggregation settings</h5>
-    <div class="gf-form">
-      <span class="gf-form-label width-12">Aggregation method</span>
-      <div class="gf-form-select-wrapper max-width-10">
-        <select class="gf-form-input"
-          ng-model="ctrl.panel.alerting.aggregator"
-          ng-options="oper as oper for oper in alertTab.aggregators"></select>
+  <div class="editor-row">
+    <div class="gf-form-group section">
+      <h5 class="section-heading">Execution</h5>
+      <div class="gf-form-inline">
+        <div class="gf-form">
+          <span class="gf-form-label">Scheduler</span>
+          <div class="gf-form-select-wrapper">
+            <select   class="gf-form-input"
+                      ng-model="ctrl.alert.scheduler"
+                      ng-options="f.value as f.text for f in ctrl.schedulers">
+            </select>
+          </div>
+        </div>
+        <div class="gf-form">
+          <span class="gf-form-label">Evaluate every</span>
+          <input class="gf-form-input max-width-7" type="text" ng-model="ctrl.alert.frequency"></input>
+        </div>
       </div>
     </div>
-
-    <div class="gf-form">
-      <span class="gf-form-label width-12">Query range  (seconds)</span>
-      <input class="gf-form-input max-width-10" type="number"
-        ng-model="ctrl.panel.alerting.queryRange" placeholder="3600"></input>
-    </div>
-
-    <div class="gf-form">
-      <span class="gf-form-label width-12">Frequency (seconds)</span>
-      <input class="gf-form-input max-width-10" type="number"
-        ng-model="ctrl.panel.alerting.frequency" placeholder="60"></input>
+    <div class="gf-form-group section">
+      <h5 class="section-heading">Notifications</h5>
+      <div class="gf-form-inline">
+        <div class="gf-form">
+          <span class="gf-form-label">Groups</span>
+          <bootstrap-tagsinput ng-model="ctrl.alert.notify" tagclass="label label-tag" placeholder="add tags">
+          </bootstrap-tagsinput>
+        </div>
+      </div>
     </div>
   </div>
+
   <div class="gf-form-group section">
-    <h5 class="section-heading">Alert info</h5>
+    <h5 class="section-heading">Information</h5>
     <div class="gf-form">
       <span class="gf-form-label width-10">Alert name</span>
       <input type="text" class="gf-form-input width-22" ng-model="ctrl.panel.alerting.name">
@@ -72,8 +108,13 @@
     </div>
   </div>
 </div>
+
 <div class="editor-row">
   <div class="gf-form-button-row">
-    <button class="btn btn-warning" ng-click="alertTab.markAsDeleted()">Delete Alert</button>
+    <button class="btn btn-danger" ng-click="ctrl.delete()" ng-show="ctrl.panel.alert">Delete</button>
+    <button class="btn btn-inverse" ng-click="ctrl.enable()" ng-hide="ctrl.panel.alert">
+      <i class="icon-gf icon-gf-alert"></i>
+      Add Alert
+    </button>
   </div>
 </div>

+ 1 - 1
public/sass/_variables.dark.scss

@@ -44,7 +44,7 @@ $brand-text-highlight:  #f7941d;
 // Status colors
 // -------------------------
 $online:                #10a345;
-$warn:                  #ffc03c;
+$warn:                  #F79520;
 $critical:              #ed2e18;
 
 // Scaffolding

+ 64 - 0
public/sass/components/_panel_graph.scss

@@ -315,3 +315,67 @@
   font-size: 12px;
 }
 
+.alert-handle-wrapper {
+  position: absolute;
+  user-select: none;
+
+  .alert-handle {
+    z-index: 10;
+    position: relative;
+    float: right;
+    padding: 0.4rem 0.6rem 0.4rem 0.4rem;
+    background-color: $btn-inverse-bg;
+    box-shadow: $search-shadow;
+    cursor: row-resize;
+    width: 100px;
+    font-size: $font-size-sm;
+    box-shadow: 4px 4px 3px 0px $body-bg;
+    border-radius: 4px;
+    border-width: 0 1px 1px 0;
+    border-style: solid;
+    border-color: $black;
+    text-align: right;
+    color: $text-muted;
+
+    .icon-gf {
+      font-size: 17px;
+      position: relative;
+      top: 0px;
+      float: left;
+    }
+  }
+
+  .alert-handle-line {
+    float: left;
+    height: 2px;
+    margin-top: 13px;
+    z-index: 0;
+    position: relative;
+  }
+
+  &--warn {
+    right: -222px;
+    width: 238px;
+
+    .alert-handle-line {
+      width: 138px;
+      background-color: $warn;
+    }
+  }
+
+  &--critical {
+    right: -105px;
+    width: 123px;
+
+    .alert-handle-line {
+      width: 23px;
+      background-color: $critical;
+    }
+  }
+
+  &--no-value {
+    .alert-handle-line {
+      display: none;
+    }
+  }
+}

+ 1 - 0
public/sass/components/_tagsinput.scss

@@ -7,6 +7,7 @@
   background-color: $input-bg;
 
   input {
+    display: inline-block;
     border: none;
     border-right: 1px solid $tight-form-border;
     margin: 0px;

+ 0 - 6
public/sass/pages/_dashboard.scss

@@ -197,12 +197,6 @@ div.flot-text {
   bottom: 0;
 }
 
-.panel-fullscreen {
-  .panel-title-container {
-    padding: 8px;
-  }
-}
-
 .panel-full-edit {
   margin-top: 20px;
   margin-bottom: 20px;

+ 1 - 1
public/vendor/flot/jquery.flot.js

@@ -1322,7 +1322,7 @@ Licensed under the MIT license.
 
             placeholder.css("padding", 0) // padding messes up the positioning
                 .children().filter(function(){
-                    return !$(this).hasClass("flot-overlay") && !$(this).hasClass('flot-base');
+                    return $(this).hasClass("flot-text");
                 }).remove();
 
             if (placeholder.css("position") == 'static')

+ 1 - 1
public/vendor/tagsinput/bootstrap-tagsinput.js

@@ -35,7 +35,7 @@
     this.inputSize = Math.max(1, this.placeholderText.length);
 
     this.$container = $('<div class="bootstrap-tagsinput"></div>');
-    this.$input = $('<input class="tight-form-input" size="' +
+    this.$input = $('<input class="gf-form-input" size="' +
                     this.inputSize + '" type="text" placeholder="' + this.placeholderText + '"/>').appendTo(this.$container);
 
     this.$element.after(this.$container);