Ver código fonte

Merge branch 'alert_conditions' into alerting

Torkel Ödegaard 9 anos atrás
pai
commit
cde1bbff78
64 arquivos alterados com 2345 adições e 1987 exclusões
  1. 7 26
      emails/templates/alert_notification.html
  2. 83 47
      pkg/api/alerting.go
  3. 2 1
      pkg/api/api.go
  4. 38 19
      pkg/api/dtos/alerting.go
  5. 3 1
      pkg/log/log.go
  6. 33 29
      pkg/models/alert.go
  7. 21 25
      pkg/models/alert_notifications.go
  8. 45 53
      pkg/models/alert_state.go
  9. 22 0
      pkg/models/annotations.go
  10. 41 63
      pkg/services/alerting/alert_rule.go
  11. 43 25
      pkg/services/alerting/alert_rule_test.go
  12. 0 16
      pkg/services/alerting/alertstates/states.go
  13. 191 0
      pkg/services/alerting/conditions.go
  14. 92 0
      pkg/services/alerting/conditions_test.go
  15. 18 52
      pkg/services/alerting/engine.go
  16. 41 26
      pkg/services/alerting/extractor.go
  17. 73 147
      pkg/services/alerting/extractor_test.go
  18. 124 113
      pkg/services/alerting/handler.go
  19. 144 131
      pkg/services/alerting/handler_test.go
  20. 21 4
      pkg/services/alerting/interfaces.go
  21. 41 11
      pkg/services/alerting/models.go
  22. 107 154
      pkg/services/alerting/notifier.go
  23. 112 123
      pkg/services/alerting/notifier_test.go
  24. 13 8
      pkg/services/alerting/reader.go
  25. 23 38
      pkg/services/alerting/result_handler.go
  26. 56 57
      pkg/services/alerting/result_handler_test.go
  27. 57 0
      pkg/services/alerting/test_rule.go
  28. 0 71
      pkg/services/alerting/transformers/aggregation.go
  29. 0 7
      pkg/services/alerting/transformers/transformer.go
  30. 19 45
      pkg/services/sqlstore/alert.go
  31. 38 78
      pkg/services/sqlstore/alert_notification.go
  32. 75 76
      pkg/services/sqlstore/alert_state.go
  33. 98 98
      pkg/services/sqlstore/alert_state_test.go
  34. 1 1
      pkg/services/sqlstore/migrations/alert_mig.go
  35. 7 5
      pkg/tsdb/graphite/graphite.go
  36. 2 2
      pkg/tsdb/models.go
  37. 2 0
      pkg/tsdb/request.go
  38. 200 0
      public/app/core/components/jsontree/jsontree.ts
  39. 1 0
      public/app/core/core.ts
  40. 1 1
      public/app/core/routes/routes.ts
  41. 1 1
      public/app/core/services/alert_srv.ts
  42. 7 8
      public/app/features/alerting/alert_def.ts
  43. 1 1
      public/app/features/alerting/alert_log_ctrl.ts
  44. 3 6
      public/app/features/alerting/alerts_ctrl.ts
  45. 18 31
      public/app/features/alerting/notification_edit_ctrl.ts
  46. 5 9
      public/app/features/alerting/notifications_list_ctrl.ts
  47. 8 7
      public/app/features/alerting/partials/alert_list.html
  48. 0 49
      public/app/features/alerting/partials/alert_log.html
  49. 19 21
      public/app/features/alerting/partials/notification_edit.html
  50. 5 1
      public/app/features/alerting/partials/notifications_list.html
  51. 5 0
      public/app/features/dashboard/viewStateSrv.js
  52. 4 4
      public/app/features/panel/panel_ctrl.ts
  53. 160 74
      public/app/plugins/panel/graph/alert_tab_ctrl.ts
  54. 8 54
      public/app/plugins/panel/graph/graph.js
  55. 2 1
      public/app/plugins/panel/graph/module.ts
  56. 126 133
      public/app/plugins/panel/graph/partials/tab_alerting.html
  57. 5 4
      public/app/plugins/panel/graph/thresholds.ts
  58. 7 26
      public/emails/alert_notification.html
  59. 1 1
      public/emails/invited_to_org.html
  60. 1 1
      public/emails/new_user_invite.html
  61. 1 1
      public/emails/signup_started.html
  62. 1 0
      public/sass/_grafana.scss
  63. 61 0
      public/sass/components/_jsontree.scss
  64. 1 1
      public/sass/components/_tags.scss

+ 7 - 26
emails/templates/alert_notification.html

@@ -1,31 +1,12 @@
-<!-- This email is sent when an existing user is added to an organization -->
+[[Subject .Subject "Grafana Alert: [[.Severity]] [[.RuleName]]"]]
 
-[[Subject .Subject "Grafana Alert: [ [[.State]] ] [[.Name]]" ]]
+<br>
+<br>
 
-Alertstate: [[.State]]<br />
-[[.AlertPageUrl]]<br />
-[[.DashboardLink]]<br />
-[[.Description]]<br />
+Alert rule: [[.RuleName]]<br>
+Alert state: [[.RuleState]]<br>
 
-[[if eq .State "Ok"]]
-    Everything is Ok     
-[[end]]
+<a href="[[.RuleLink]]">Link to alert rule</a>
 
-<img src="[[.DashboardImage]]" />
+<br>
 
-[[if ne .State "Ok" ]]
-    <table class="row">
-        <tr>
-            <td class="expander">Serie</td>
-            <td class="expander">State</td>
-            <td class="expander">Actual value</td>
-        </tr>
-        [[ range $ta := .TriggeredAlerts]]            
-        <tr>
-            <td class="expander">[[$ta.Name]]</td>
-            <td class="expander">[[$ta.State]]</td>
-            <td class="expander">[[$ta.ActualValue]]</td>
-        </tr>
-        [[end]]
-    </table>
-[[end]]

+ 83 - 47
pkg/api/alerting.go

@@ -1,10 +1,13 @@
 package api
 
 import (
+	"fmt"
+
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/alerting"
 )
 
 func ValidateOrgAlert(c *middleware.Context) {
@@ -36,16 +39,17 @@ func GetAlerts(c *middleware.Context) Response {
 	}
 
 	dashboardIds := make([]int64, 0)
-	alertDTOs := make([]*dtos.AlertRuleDTO, 0)
+	alertDTOs := make([]*dtos.AlertRule, 0)
 	for _, alert := range query.Result {
 		dashboardIds = append(dashboardIds, alert.DashboardId)
-		alertDTOs = append(alertDTOs, &dtos.AlertRuleDTO{
+		alertDTOs = append(alertDTOs, &dtos.AlertRule{
 			Id:          alert.Id,
 			DashboardId: alert.DashboardId,
 			PanelId:     alert.PanelId,
 			Name:        alert.Name,
 			Description: alert.Description,
 			State:       alert.State,
+			Severity:    alert.Severity,
 		})
 	}
 
@@ -71,6 +75,40 @@ func GetAlerts(c *middleware.Context) Response {
 	return Json(200, alertDTOs)
 }
 
+// POST /api/alerts/test
+func AlertTest(c *middleware.Context, dto dtos.AlertTestCommand) Response {
+	backendCmd := alerting.AlertTestCommand{
+		OrgId:     c.OrgId,
+		Dashboard: dto.Dashboard,
+		PanelId:   dto.PanelId,
+	}
+
+	if err := bus.Dispatch(&backendCmd); err != nil {
+		if validationErr, ok := err.(alerting.AlertValidationError); ok {
+			return ApiError(422, validationErr.Error(), nil)
+		}
+		return ApiError(500, "Failed to test rule", err)
+	}
+
+	res := backendCmd.Result
+
+	dtoRes := &dtos.AlertTestResult{
+		Firing: res.Firing,
+	}
+
+	if res.Error != nil {
+		dtoRes.Error = res.Error.Error()
+	}
+
+	for _, log := range res.Logs {
+		dtoRes.Logs = append(dtoRes.Logs, &dtos.AlertTestResultLog{Message: log.Message, Data: log.Data})
+	}
+
+	dtoRes.TimeMs = fmt.Sprintf("%1.3fms", res.GetDurationMs())
+
+	return Json(200, dtoRes)
+}
+
 // GET /api/alerts/:id
 func GetAlert(c *middleware.Context) Response {
 	id := c.ParamsInt64(":alertId")
@@ -101,55 +139,53 @@ func DelAlert(c *middleware.Context) Response {
 	return Json(200, resp)
 }
 
-// GET /api/alerts/events/:id
-func GetAlertStates(c *middleware.Context) Response {
-	alertId := c.ParamsInt64(":alertId")
-
-	query := models.GetAlertsStateQuery{
-		AlertId: alertId,
-	}
-
-	if err := bus.Dispatch(&query); err != nil {
-		return ApiError(500, "Failed get alert state log", err)
-	}
-
-	return Json(200, query.Result)
-}
-
-// PUT /api/alerts/events/:id
-func PutAlertState(c *middleware.Context, cmd models.UpdateAlertStateCommand) Response {
-	cmd.AlertId = c.ParamsInt64(":alertId")
-	cmd.OrgId = c.OrgId
-
-	query := models.GetAlertByIdQuery{Id: cmd.AlertId}
-	if err := bus.Dispatch(&query); err != nil {
-		return ApiError(500, "Failed to get alertstate", err)
-	}
-
-	if query.Result.OrgId != 0 && query.Result.OrgId != c.OrgId {
-		return ApiError(500, "Alert not found", nil)
-	}
-
-	if err := bus.Dispatch(&cmd); err != nil {
-		return ApiError(500, "Failed to set new state", err)
-	}
-
-	return Json(200, cmd.Result)
-}
+// // GET /api/alerts/events/:id
+// func GetAlertStates(c *middleware.Context) Response {
+// 	alertId := c.ParamsInt64(":alertId")
+//
+// 	query := models.GetAlertsStateQuery{
+// 		AlertId: alertId,
+// 	}
+//
+// 	if err := bus.Dispatch(&query); err != nil {
+// 		return ApiError(500, "Failed get alert state log", err)
+// 	}
+//
+// 	return Json(200, query.Result)
+// }
+//
+// // PUT /api/alerts/events/:id
+// func PutAlertState(c *middleware.Context, cmd models.UpdateAlertStateCommand) Response {
+// 	cmd.AlertId = c.ParamsInt64(":alertId")
+// 	cmd.OrgId = c.OrgId
+//
+// 	query := models.GetAlertByIdQuery{Id: cmd.AlertId}
+// 	if err := bus.Dispatch(&query); err != nil {
+// 		return ApiError(500, "Failed to get alertstate", err)
+// 	}
+//
+// 	if query.Result.OrgId != 0 && query.Result.OrgId != c.OrgId {
+// 		return ApiError(500, "Alert not found", nil)
+// 	}
+//
+// 	if err := bus.Dispatch(&cmd); err != nil {
+// 		return ApiError(500, "Failed to set new state", err)
+// 	}
+//
+// 	return Json(200, cmd.Result)
+// }
 
 func GetAlertNotifications(c *middleware.Context) Response {
-	query := &models.GetAlertNotificationQuery{
-		OrgID: c.OrgId,
-	}
+	query := &models.GetAlertNotificationsQuery{OrgId: c.OrgId}
 
 	if err := bus.Dispatch(query); err != nil {
 		return ApiError(500, "Failed to get alert notifications", err)
 	}
 
-	var result []dtos.AlertNotificationDTO
+	var result []dtos.AlertNotification
 
 	for _, notification := range query.Result {
-		result = append(result, dtos.AlertNotificationDTO{
+		result = append(result, dtos.AlertNotification{
 			Id:      notification.Id,
 			Name:    notification.Name,
 			Type:    notification.Type,
@@ -162,8 +198,8 @@ func GetAlertNotifications(c *middleware.Context) Response {
 }
 
 func GetAlertNotificationById(c *middleware.Context) Response {
-	query := &models.GetAlertNotificationQuery{
-		OrgID: c.OrgId,
+	query := &models.GetAlertNotificationsQuery{
+		OrgId: c.OrgId,
 		Id:    c.ParamsInt64("notificationId"),
 	}
 
@@ -175,7 +211,7 @@ func GetAlertNotificationById(c *middleware.Context) Response {
 }
 
 func CreateAlertNotification(c *middleware.Context, cmd models.CreateAlertNotificationCommand) Response {
-	cmd.OrgID = c.OrgId
+	cmd.OrgId = c.OrgId
 
 	if err := bus.Dispatch(&cmd); err != nil {
 		return ApiError(500, "Failed to create alert notification", err)
@@ -185,7 +221,7 @@ func CreateAlertNotification(c *middleware.Context, cmd models.CreateAlertNotifi
 }
 
 func UpdateAlertNotification(c *middleware.Context, cmd models.UpdateAlertNotificationCommand) Response {
-	cmd.OrgID = c.OrgId
+	cmd.OrgId = c.OrgId
 
 	if err := bus.Dispatch(&cmd); err != nil {
 		return ApiError(500, "Failed to update alert notification", err)
@@ -204,5 +240,5 @@ func DeleteAlertNotification(c *middleware.Context) Response {
 		return ApiError(500, "Failed to delete alert notification", err)
 	}
 
-	return Json(200, map[string]interface{}{"notificationId": cmd.Id})
+	return ApiSuccess("Notification deleted")
 }

+ 2 - 1
pkg/api/api.go

@@ -246,7 +246,8 @@ func Register(r *macaron.Macaron) {
 		r.Get("/metrics", wrap(GetInternalMetrics))
 
 		r.Group("/alerts", func() {
-			r.Get("/:alertId/states", wrap(GetAlertStates))
+			r.Post("/test", bind(dtos.AlertTestCommand{}), wrap(AlertTest))
+			//r.Get("/:alertId/states", wrap(GetAlertStates))
 			//r.Put("/:alertId/state", bind(m.UpdateAlertStateCommand{}), wrap(PutAlertState))
 			r.Get("/:alertId", ValidateOrgAlert, wrap(GetAlert))
 			//r.Delete("/:alertId", ValidateOrgAlert, wrap(DelAlert)) disabled until we know how to handle it dashboard updates

+ 38 - 19
pkg/api/dtos/alerting.go

@@ -1,31 +1,50 @@
 package dtos
 
-import "time"
-
-type AlertRuleDTO struct {
-	Id           int64   `json:"id"`
-	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"`
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+type AlertRule struct {
+	Id          int64               `json:"id"`
+	DashboardId int64               `json:"dashboardId"`
+	PanelId     int64               `json:"panelId"`
+	Name        string              `json:"name"`
+	Description string              `json:"description"`
+	State       m.AlertStateType    `json:"state"`
+	Severity    m.AlertSeverityType `json:"severity"`
 
 	DashbboardUri string `json:"dashboardUri"`
 }
 
-type AlertNotificationDTO struct {
+type AlertNotification struct {
 	Id      int64     `json:"id"`
 	Name    string    `json:"name"`
 	Type    string    `json:"type"`
 	Created time.Time `json:"created"`
 	Updated time.Time `json:"updated"`
 }
+
+type AlertTestCommand struct {
+	Dashboard *simplejson.Json `json:"dashboard" binding:"Required"`
+	PanelId   int64            `json:"panelId" binding:"Required"`
+}
+
+type AlertTestResult struct {
+	Firing bool                  `json:"firing"`
+	TimeMs string                `json:"timeMs"`
+	Error  string                `json:"error,omitempty"`
+	Logs   []*AlertTestResultLog `json:"logs,omitempty"`
+}
+
+type AlertTestResultLog struct {
+	Message string      `json:"message"`
+	Data    interface{} `json:"data"`
+}
+
+type AlertEvent struct {
+	Metric string  `json:"metric"`
+	Value  float64 `json:"value"`
+}

+ 3 - 1
pkg/log/log.go

@@ -116,7 +116,9 @@ func getFilters(filterStrArray []string) map[string]log15.Lvl {
 
 	for _, filterStr := range filterStrArray {
 		parts := strings.Split(filterStr, ":")
-		filterMap[parts[0]] = getLogLevelFromString(parts[1])
+		if len(parts) > 1 {
+			filterMap[parts[0]] = getLogLevelFromString(parts[1])
+		}
 	}
 
 	return filterMap

+ 33 - 29
pkg/models/alert.go

@@ -6,6 +6,29 @@ import (
 	"github.com/grafana/grafana/pkg/components/simplejson"
 )
 
+type AlertStateType string
+type AlertSeverityType string
+
+const (
+	AlertStatePending AlertStateType = "pending"
+	AlertStateFiring  AlertStateType = "firing"
+	AlertStateOK      AlertStateType = "ok"
+)
+
+func (s AlertStateType) IsValid() bool {
+	return s == AlertStatePending || s == AlertStateFiring || s == AlertStateOK
+}
+
+const (
+	AlertSeverityCritical AlertSeverityType = "critical"
+	AlertSeverityWarning  AlertSeverityType = "warning"
+	AlertSeverityInfo     AlertSeverityType = "info"
+)
+
+func (s AlertSeverityType) IsValid() bool {
+	return s == AlertSeverityCritical || s == AlertSeverityInfo || s == AlertSeverityWarning
+}
+
 type Alert struct {
 	Id          int64
 	OrgId       int64
@@ -13,7 +36,8 @@ type Alert struct {
 	PanelId     int64
 	Name        string
 	Description string
-	State       string
+	Severity    AlertSeverityType
+	State       AlertStateType
 	Handler     int64
 	Enabled     bool
 	Frequency   int64
@@ -31,7 +55,7 @@ func (alert *Alert) ValidToSave() bool {
 	return alert.DashboardId != 0 && alert.OrgId != 0 && alert.PanelId != 0
 }
 
-func (alert *Alert) ShouldUpdateState(newState string) bool {
+func (alert *Alert) ShouldUpdateState(newState AlertStateType) bool {
 	return alert.State != newState
 }
 
@@ -73,25 +97,6 @@ type HeartBeatCommand struct {
 	Result   AlertingClusterInfo
 }
 
-type AlertChange struct {
-	Id               int64            `json:"id"`
-	OrgId            int64            `json:"-"`
-	AlertId          int64            `json:"alertId"`
-	UpdatedBy        int64            `json:"updatedBy"`
-	NewAlertSettings *simplejson.Json `json:"newAlertSettings"`
-	Type             string           `json:"type"`
-	Created          time.Time        `json:"created"`
-}
-
-// Commands
-type CreateAlertChangeCommand struct {
-	OrgId            int64
-	AlertId          int64
-	UpdatedBy        int64
-	NewAlertSettings *simplejson.Json
-	Type             string
-}
-
 type SaveAlertsCommand struct {
 	DashboardId int64
 	UserId      int64
@@ -100,6 +105,13 @@ type SaveAlertsCommand struct {
 	Alerts []*Alert
 }
 
+type SetAlertStateCommand struct {
+	AlertId   int64
+	OrgId     int64
+	State     AlertStateType
+	Timestamp time.Time
+}
+
 type DeleteAlertCommand struct {
 	AlertId int64
 }
@@ -123,11 +135,3 @@ type GetAlertByIdQuery struct {
 
 	Result *Alert
 }
-
-type GetAlertChangesQuery struct {
-	OrgId   int64
-	Limit   int64
-	SinceId int64
-
-	Result []*AlertChange
-}

+ 21 - 25
pkg/models/alert_notifications.go

@@ -7,34 +7,31 @@ import (
 )
 
 type AlertNotification struct {
-	Id            int64            `json:"id"`
-	OrgId         int64            `json:"-"`
-	Name          string           `json:"name"`
-	Type          string           `json:"type"`
-	AlwaysExecute bool             `json:"alwaysExecute"`
-	Settings      *simplejson.Json `json:"settings"`
-	Created       time.Time        `json:"created"`
-	Updated       time.Time        `json:"updated"`
+	Id       int64            `json:"id"`
+	OrgId    int64            `json:"-"`
+	Name     string           `json:"name"`
+	Type     string           `json:"type"`
+	Settings *simplejson.Json `json:"settings"`
+	Created  time.Time        `json:"created"`
+	Updated  time.Time        `json:"updated"`
 }
 
 type CreateAlertNotificationCommand struct {
-	Name          string           `json:"name"  binding:"Required"`
-	Type          string           `json:"type"  binding:"Required"`
-	AlwaysExecute bool             `json:"alwaysExecute"`
-	OrgID         int64            `json:"-"`
-	Settings      *simplejson.Json `json:"settings"`
+	Name     string           `json:"name"  binding:"Required"`
+	Type     string           `json:"type"  binding:"Required"`
+	Settings *simplejson.Json `json:"settings"`
 
+	OrgId  int64 `json:"-"`
 	Result *AlertNotification
 }
 
 type UpdateAlertNotificationCommand struct {
-	Id            int64            `json:"id"  binding:"Required"`
-	Name          string           `json:"name"  binding:"Required"`
-	Type          string           `json:"type"  binding:"Required"`
-	AlwaysExecute bool             `json:"alwaysExecute"`
-	OrgID         int64            `json:"-"`
-	Settings      *simplejson.Json `json:"settings"  binding:"Required"`
+	Id       int64            `json:"id"  binding:"Required"`
+	Name     string           `json:"name"  binding:"Required"`
+	Type     string           `json:"type"  binding:"Required"`
+	Settings *simplejson.Json `json:"settings"  binding:"Required"`
 
+	OrgId  int64 `json:"-"`
 	Result *AlertNotification
 }
 
@@ -43,12 +40,11 @@ type DeleteAlertNotificationCommand struct {
 	OrgId int64
 }
 
-type GetAlertNotificationQuery struct {
-	Name                 string
-	Id                   int64
-	Ids                  []int64
-	OrgID                int64
-	IncludeAlwaysExecute bool
+type GetAlertNotificationsQuery struct {
+	Name  string
+	Id    int64
+	Ids   []int64
+	OrgId int64
 
 	Result []*AlertNotification
 }

+ 45 - 53
pkg/models/alert_state.go

@@ -1,55 +1,47 @@
 package models
 
-import (
-	"time"
-
-	"github.com/grafana/grafana/pkg/components/simplejson"
-	"github.com/grafana/grafana/pkg/services/alerting/alertstates"
-)
-
-type AlertState struct {
-	Id              int64            `json:"-"`
-	OrgId           int64            `json:"-"`
-	AlertId         int64            `json:"alertId"`
-	State           string           `json:"state"`
-	Created         time.Time        `json:"created"`
-	Info            string           `json:"info"`
-	TriggeredAlerts *simplejson.Json `json:"triggeredAlerts"`
-}
-
-func (this *UpdateAlertStateCommand) IsValidState() bool {
-	for _, v := range alertstates.ValidStates {
-		if this.State == v {
-			return true
-		}
-	}
-	return false
-}
-
-// Commands
-
-type UpdateAlertStateCommand struct {
-	AlertId         int64            `json:"alertId" binding:"Required"`
-	OrgId           int64            `json:"orgId" binding:"Required"`
-	State           string           `json:"state" binding:"Required"`
-	Info            string           `json:"info"`
-	TriggeredAlerts *simplejson.Json `json:"triggeredAlerts"`
-
-	Result *Alert
-}
-
-// Queries
-
-type GetAlertsStateQuery struct {
-	OrgId   int64 `json:"orgId" binding:"Required"`
-	AlertId int64 `json:"alertId" binding:"Required"`
-
-	Result *[]AlertState
-}
-
-type GetLastAlertStateQuery struct {
-	AlertId int64
-	OrgId   int64
-
-	Result *AlertState
-}
+// type AlertState struct {
+// 	Id              int64            `json:"-"`
+// 	OrgId           int64            `json:"-"`
+// 	AlertId         int64            `json:"alertId"`
+// 	State           string           `json:"state"`
+// 	Created         time.Time        `json:"created"`
+// 	Info            string           `json:"info"`
+// 	TriggeredAlerts *simplejson.Json `json:"triggeredAlerts"`
+// }
+//
+// func (this *UpdateAlertStateCommand) IsValidState() bool {
+// 	for _, v := range alertstates.ValidStates {
+// 		if this.State == v {
+// 			return true
+// 		}
+// 	}
+// 	return false
+// }
+//
+// // Commands
+//
+// type UpdateAlertStateCommand struct {
+// 	AlertId int64  `json:"alertId" binding:"Required"`
+// 	OrgId   int64  `json:"orgId" binding:"Required"`
+// 	State   string `json:"state" binding:"Required"`
+// 	Info    string `json:"info"`
+//
+// 	Result *Alert
+// }
+//
+// // Queries
+//
+// type GetAlertsStateQuery struct {
+// 	OrgId   int64 `json:"orgId" binding:"Required"`
+// 	AlertId int64 `json:"alertId" binding:"Required"`
+//
+// 	Result *[]AlertState
+// }
+//
+// type GetLastAlertStateQuery struct {
+// 	AlertId int64
+// 	OrgId   int64
+//
+// 	Result *AlertState
+// }

+ 22 - 0
pkg/models/annotations.go

@@ -0,0 +1,22 @@
+package models
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+)
+
+type AnnotationType string
+
+type AnnotationEvent struct {
+	Id        int64
+	OrgId     int64
+	Type      AnnotationType
+	Title     string
+	Text      string
+	AlertId   int64
+	UserId    int64
+	Timestamp time.Time
+
+	Data *simplejson.Json
+}

+ 41 - 63
pkg/services/alerting/alert_rule.go

@@ -4,31 +4,32 @@ import (
 	"fmt"
 	"regexp"
 	"strconv"
-	"strings"
 
 	"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     transformers.Transformer
-
-	NotificationGroups []int64
+	Id            int64
+	OrgId         int64
+	DashboardId   int64
+	PanelId       int64
+	Frequency     int64
+	Name          string
+	Description   string
+	State         m.AlertStateType
+	Severity      m.AlertSeverityType
+	Conditions    []AlertCondition
+	Notifications []int64
+}
+
+type AlertValidationError struct {
+	Reason string
+}
+
+func (e AlertValidationError) Error() string {
+	return e.Reason
 }
 
 var (
@@ -59,60 +60,37 @@ func NewAlertRuleFromDBModel(ruleDef *m.Alert) (*AlertRule, error) {
 	model := &AlertRule{}
 	model.Id = ruleDef.Id
 	model.OrgId = ruleDef.OrgId
+	model.DashboardId = ruleDef.DashboardId
+	model.PanelId = ruleDef.PanelId
 	model.Name = ruleDef.Name
 	model.Description = ruleDef.Description
-	model.State = ruleDef.State
 	model.Frequency = ruleDef.Frequency
+	model.Severity = ruleDef.Severity
+	model.State = ruleDef.State
 
-	ngs := ruleDef.Settings.Get("notificationGroups").MustString()
-	var ids []int64
-	for _, v := range strings.Split(ngs, ",") {
-		id, err := strconv.Atoi(v)
-		if err == nil {
-			ids = append(ids, int64(id))
+	for _, v := range ruleDef.Settings.Get("notifications").MustArray() {
+		jsonModel := simplejson.NewFromAny(v)
+		if id, err := jsonModel.Get("id").Int64(); err != nil {
+			return nil, AlertValidationError{Reason: "Invalid notification schema"}
+		} else {
+			model.Notifications = append(model.Notifications, id)
 		}
 	}
 
-	model.NotificationGroups = ids
-
-	critical := ruleDef.Settings.Get("crit")
-	model.Critical = Level{
-		Operator: critical.Get("op").MustString(),
-		Value:    critical.Get("value").MustFloat64(),
-	}
-
-	warning := ruleDef.Settings.Get("warn")
-	model.Warning = Level{
-		Operator: warning.Get("op").MustString(),
-		Value:    warning.Get("value").MustFloat64(),
-	}
-
-	model.Transform = ruleDef.Settings.Get("transform").Get("type").MustString()
-	if model.Transform == "" {
-		return nil, fmt.Errorf("missing transform")
-	}
-
-	model.TransformParams = *ruleDef.Settings.Get("transform")
-
-	if model.Transform == "aggregation" {
-		method := ruleDef.Settings.Get("transform").Get("method").MustString()
-		model.Transformer = transformers.NewAggregationTransformer(method)
-	}
-
-	query := ruleDef.Settings.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(),
-	}
-
-	if model.Query.Query == "" {
-		return nil, fmt.Errorf("missing query.query")
+	for index, condition := range ruleDef.Settings.Get("conditions").MustArray() {
+		conditionModel := simplejson.NewFromAny(condition)
+		switch conditionModel.Get("type").MustString() {
+		case "query":
+			queryCondition, err := NewQueryCondition(conditionModel, index)
+			if err != nil {
+				return nil, err
+			}
+			model.Conditions = append(model.Conditions, queryCondition)
+		}
 	}
 
-	if model.Query.DatasourceId == 0 {
-		return nil, fmt.Errorf("missing query.datasourceId")
+	if len(model.Conditions) == 0 {
+		return nil, fmt.Errorf("Alert is missing conditions")
 	}
 
 	return model, nil

+ 43 - 25
pkg/services/alerting/alert_rule_test.go

@@ -31,33 +31,30 @@ func TestAlertRuleModel(t *testing.T) {
 			So(seconds, ShouldEqual, 1)
 		})
 
-		Convey("", func() {
+		Convey("can construct alert rule model", func() {
 			json := `
 			{
 				"name": "name2",
 				"description": "desc2",
 				"handler": 0,
 				"enabled": true,
-				"crit": {
-					"value": 20,
-					"op": ">"
-				},
-				"warn": {
-					"value": 10,
-					"op": ">"
-				},
 				"frequency": "60s",
-				"query": {
-					"from": "5m",
-					"refId": "A",
-					"to": "now",
-					"query": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)",
-					"datasourceId": 1
-				},
-				"transform": {
-					"type": "avg",
-					"name": "aggregation"
-				}
+        "conditions": [
+          {
+            "type": "query",
+            "query":  {
+              "params": ["A", "5m", "now"],
+              "datasourceId": 1,
+              "model": {"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}
+            },
+            "reducer": {"type": "avg", "params": []},
+            "evaluator": {"type": ">", "params": [100]}
+					}
+        ],
+        "notifications": [
+					{"id": 1134},
+					{"id": 22}
+				]
 			}
 			`
 
@@ -72,15 +69,36 @@ func TestAlertRuleModel(t *testing.T) {
 
 				Settings: alertJSON,
 			}
-			alertRule, err := NewAlertRuleFromDBModel(alert)
 
+			alertRule, err := NewAlertRuleFromDBModel(alert)
 			So(err, ShouldBeNil)
 
-			So(alertRule.Warning.Operator, ShouldEqual, ">")
-			So(alertRule.Warning.Value, ShouldEqual, 10)
+			So(alertRule.Conditions, ShouldHaveLength, 1)
+
+			Convey("Can read query condition from json model", func() {
+				queryCondition, ok := alertRule.Conditions[0].(*QueryCondition)
+				So(ok, ShouldBeTrue)
+
+				So(queryCondition.Query.From, ShouldEqual, "5m")
+				So(queryCondition.Query.To, ShouldEqual, "now")
+				So(queryCondition.Query.DatasourceId, ShouldEqual, 1)
+
+				Convey("Can read query reducer", func() {
+					reducer, ok := queryCondition.Reducer.(*SimpleReducer)
+					So(ok, ShouldBeTrue)
+					So(reducer.Type, ShouldEqual, "avg")
+				})
+
+				Convey("Can read evaluator", func() {
+					evaluator, ok := queryCondition.Evaluator.(*DefaultAlertEvaluator)
+					So(ok, ShouldBeTrue)
+					So(evaluator.Type, ShouldEqual, ">")
+				})
+			})
 
-			So(alertRule.Critical.Operator, ShouldEqual, ">")
-			So(alertRule.Critical.Value, ShouldEqual, 20)
+			Convey("Can read notifications", func() {
+				So(len(alertRule.Notifications), ShouldEqual, 2)
+			})
 		})
 	})
 }

+ 0 - 16
pkg/services/alerting/alertstates/states.go

@@ -1,16 +0,0 @@
-package alertstates
-
-var (
-	ValidStates = []string{
-		Ok,
-		Warn,
-		Critical,
-		Unknown,
-	}
-
-	Ok       = "OK"
-	Warn     = "WARN"
-	Critical = "CRITICAL"
-	Pending  = "PENDING"
-	Unknown  = "UNKNOWN"
-)

+ 191 - 0
pkg/services/alerting/conditions.go

@@ -0,0 +1,191 @@
+package alerting
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/tsdb"
+)
+
+type QueryCondition struct {
+	Index         int
+	Query         AlertQuery
+	Reducer       QueryReducer
+	Evaluator     AlertEvaluator
+	HandleRequest tsdb.HandleRequestFunc
+}
+
+func (c *QueryCondition) Eval(context *AlertResultContext) {
+	seriesList, err := c.executeQuery(context)
+	if err != nil {
+		context.Error = err
+		return
+	}
+
+	for _, series := range seriesList {
+		reducedValue := c.Reducer.Reduce(series)
+		pass := c.Evaluator.Eval(series, reducedValue)
+
+		if context.IsTestRun {
+			context.Logs = append(context.Logs, &AlertResultLogEntry{
+				Message: fmt.Sprintf("Condition[%d]: Eval: %v, Metric: %s, Value: %1.3f", c.Index, pass, series.Name, reducedValue),
+			})
+		}
+
+		if pass {
+			context.Events = append(context.Events, &AlertEvent{
+				Metric: series.Name,
+				Value:  reducedValue,
+			})
+			context.Firing = true
+			break
+		}
+	}
+}
+
+func (c *QueryCondition) executeQuery(context *AlertResultContext) (tsdb.TimeSeriesSlice, error) {
+	getDsInfo := &m.GetDataSourceByIdQuery{
+		Id:    c.Query.DatasourceId,
+		OrgId: context.Rule.OrgId,
+	}
+
+	if err := bus.Dispatch(getDsInfo); err != nil {
+		return nil, fmt.Errorf("Could not find datasource")
+	}
+
+	req := c.getRequestForAlertRule(getDsInfo.Result)
+	result := make(tsdb.TimeSeriesSlice, 0)
+
+	resp, err := c.HandleRequest(req)
+	if err != nil {
+		return nil, fmt.Errorf("tsdb.HandleRequest() error %v", err)
+	}
+
+	for _, v := range resp.Results {
+		if v.Error != nil {
+			return nil, fmt.Errorf("tsdb.HandleRequest() response error %v", v)
+		}
+
+		result = append(result, v.Series...)
+
+		if context.IsTestRun {
+			context.Logs = append(context.Logs, &AlertResultLogEntry{
+				Message: fmt.Sprintf("Condition[%d]: Query Result", c.Index),
+				Data:    v.Series,
+			})
+		}
+	}
+
+	return result, nil
+}
+
+func (c *QueryCondition) getRequestForAlertRule(datasource *m.DataSource) *tsdb.Request {
+	req := &tsdb.Request{
+		TimeRange: tsdb.TimeRange{
+			From: c.Query.From,
+			To:   c.Query.To,
+		},
+		Queries: []*tsdb.Query{
+			{
+				RefId: "A",
+				Query: c.Query.Model.Get("target").MustString(),
+				DataSource: &tsdb.DataSourceInfo{
+					Id:       datasource.Id,
+					Name:     datasource.Name,
+					PluginId: datasource.Type,
+					Url:      datasource.Url,
+				},
+			},
+		},
+	}
+
+	return req
+}
+
+func NewQueryCondition(model *simplejson.Json, index int) (*QueryCondition, error) {
+	condition := QueryCondition{}
+	condition.Index = index
+	condition.HandleRequest = tsdb.HandleRequest
+
+	queryJson := model.Get("query")
+
+	condition.Query.Model = queryJson.Get("model")
+	condition.Query.From = queryJson.Get("params").MustArray()[1].(string)
+	condition.Query.To = queryJson.Get("params").MustArray()[2].(string)
+	condition.Query.DatasourceId = queryJson.Get("datasourceId").MustInt64()
+
+	reducerJson := model.Get("reducer")
+	condition.Reducer = NewSimpleReducer(reducerJson.Get("type").MustString())
+
+	evaluatorJson := model.Get("evaluator")
+	evaluator, err := NewDefaultAlertEvaluator(evaluatorJson)
+	if err != nil {
+		return nil, err
+	}
+
+	condition.Evaluator = evaluator
+	return &condition, nil
+}
+
+type SimpleReducer struct {
+	Type string
+}
+
+func (s *SimpleReducer) Reduce(series *tsdb.TimeSeries) float64 {
+	var value float64 = 0
+
+	switch s.Type {
+	case "avg":
+		for _, point := range series.Points {
+			value += point[0]
+		}
+		value = value / float64(len(series.Points))
+	}
+
+	return value
+}
+
+func NewSimpleReducer(typ string) *SimpleReducer {
+	return &SimpleReducer{Type: typ}
+}
+
+type DefaultAlertEvaluator struct {
+	Type      string
+	Threshold float64
+}
+
+func (e *DefaultAlertEvaluator) Eval(series *tsdb.TimeSeries, reducedValue float64) bool {
+	switch e.Type {
+	case ">":
+		return reducedValue > e.Threshold
+	case "<":
+		return reducedValue < e.Threshold
+	}
+
+	return false
+}
+
+func NewDefaultAlertEvaluator(model *simplejson.Json) (*DefaultAlertEvaluator, error) {
+	evaluator := &DefaultAlertEvaluator{}
+
+	evaluator.Type = model.Get("type").MustString()
+	if evaluator.Type == "" {
+		return nil, AlertValidationError{Reason: "Evaluator missing type property"}
+	}
+
+	params := model.Get("params").MustArray()
+	if len(params) == 0 {
+		return nil, AlertValidationError{Reason: "Evaluator missing threshold parameter"}
+	}
+
+	threshold, ok := params[0].(json.Number)
+	if !ok {
+		return nil, AlertValidationError{Reason: "Evaluator has invalid threshold parameter"}
+	}
+
+	evaluator.Threshold, _ = threshold.Float64()
+	return evaluator, nil
+}

+ 92 - 0
pkg/services/alerting/conditions_test.go

@@ -0,0 +1,92 @@
+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/grafana/grafana/pkg/tsdb"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestQueryCondition(t *testing.T) {
+
+	Convey("when evaluating query condition", t, func() {
+
+		queryConditionScenario("Given avg() and > 100", func(ctx *queryConditionTestContext) {
+
+			ctx.reducer = `{"type": "avg"}`
+			ctx.evaluator = `{"type": ">", "params": [100]}`
+
+			Convey("should fire when avg is above 100", func() {
+				ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", [][2]float64{{120, 0}})}
+				ctx.exec()
+
+				So(ctx.result.Error, ShouldBeNil)
+				So(ctx.result.Firing, ShouldBeTrue)
+			})
+
+			Convey("Should not fire when avg is below 100", func() {
+				ctx.series = tsdb.TimeSeriesSlice{tsdb.NewTimeSeries("test1", [][2]float64{{90, 0}})}
+				ctx.exec()
+
+				So(ctx.result.Error, ShouldBeNil)
+				So(ctx.result.Firing, ShouldBeFalse)
+			})
+		})
+	})
+}
+
+type queryConditionTestContext struct {
+	reducer   string
+	evaluator string
+	series    tsdb.TimeSeriesSlice
+	result    *AlertResultContext
+}
+
+type queryConditionScenarioFunc func(c *queryConditionTestContext)
+
+func (ctx *queryConditionTestContext) exec() {
+	jsonModel, err := simplejson.NewJson([]byte(`{
+            "type": "query",
+            "query":  {
+              "params": ["A", "5m", "now"],
+              "datasourceId": 1,
+              "model": {"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}
+            },
+            "reducer":` + ctx.reducer + `,
+            "evaluator":` + ctx.evaluator + `
+          }`))
+	So(err, ShouldBeNil)
+
+	condition, err := NewQueryCondition(jsonModel, 0)
+	So(err, ShouldBeNil)
+
+	condition.HandleRequest = func(req *tsdb.Request) (*tsdb.Response, error) {
+		return &tsdb.Response{
+			Results: map[string]*tsdb.QueryResult{
+				"A": {Series: ctx.series},
+			},
+		}, nil
+	}
+
+	condition.Eval(ctx.result)
+}
+
+func queryConditionScenario(desc string, fn queryConditionScenarioFunc) {
+	Convey(desc, func() {
+
+		bus.AddHandler("test", func(query *m.GetDataSourceByIdQuery) error {
+			query.Result = &m.DataSource{Id: 1, Type: "graphite"}
+			return nil
+		})
+
+		ctx := &queryConditionTestContext{}
+		ctx.result = &AlertResultContext{
+			Rule: &AlertRule{},
+		}
+
+		fn(ctx)
+	})
+}

+ 18 - 52
pkg/services/alerting/engine.go

@@ -1,38 +1,34 @@
 package alerting
 
 import (
-	"fmt"
 	"time"
 
 	"github.com/benbjohnson/clock"
 	"github.com/grafana/grafana/pkg/log"
-	"github.com/grafana/grafana/pkg/services/alerting/alertstates"
 )
 
 type Engine struct {
 	execQueue       chan *AlertJob
-	resultQueue     chan *AlertResult
+	resultQueue     chan *AlertResultContext
 	clock           clock.Clock
 	ticker          *Ticker
 	scheduler       Scheduler
-	handler         AlertingHandler
+	handler         AlertHandler
 	ruleReader      RuleReader
 	log             log.Logger
 	responseHandler ResultHandler
-	alertJobTimeout time.Duration
 }
 
 func NewEngine() *Engine {
 	e := &Engine{
 		ticker:          NewTicker(time.Now(), time.Second*0, clock.New()),
 		execQueue:       make(chan *AlertJob, 1000),
-		resultQueue:     make(chan *AlertResult, 1000),
+		resultQueue:     make(chan *AlertResultContext, 1000),
 		scheduler:       NewScheduler(),
 		handler:         NewHandler(),
 		ruleReader:      NewRuleReader(),
 		log:             log.New("alerting.engine"),
 		responseHandler: NewResultHandler(),
-		alertJobTimeout: time.Second * 5,
 	}
 
 	return e
@@ -75,41 +71,25 @@ func (e *Engine) alertingTicker() {
 }
 
 func (e *Engine) execDispatch() {
-	defer func() {
-		if err := recover(); err != nil {
-			e.log.Error("Scheduler Panic: stopping executor", "error", err, "stack", log.Stack(1))
-		}
-	}()
-
 	for job := range e.execQueue {
-		log.Trace("Alerting: engine:execDispatch() starting job %s", job.Rule.Name)
-		job.Running = true
-		e.executeJob(job)
+		e.log.Debug("Starting executing alert rule %s", job.Rule.Name)
+		go e.executeJob(job)
 	}
 }
 
 func (e *Engine) executeJob(job *AlertJob) {
-	startTime := time.Now()
-
-	resultChan := make(chan *AlertResult, 1)
-	go e.handler.Execute(job, resultChan)
-
-	select {
-	case <-time.After(e.alertJobTimeout):
-		e.resultQueue <- &AlertResult{
-			State:     alertstates.Pending,
-			Error:     fmt.Errorf("Timeout"),
-			AlertJob:  job,
-			StartTime: startTime,
-			EndTime:   time.Now(),
+	defer func() {
+		if err := recover(); err != nil {
+			e.log.Error("Execute Alert Panic", "error", err, "stack", log.Stack(1))
 		}
-		close(resultChan)
-		e.log.Debug("Job Execution timeout", "alertRuleId", job.Rule.Id)
-	case result := <-resultChan:
-		duration := float64(result.EndTime.Nanosecond()-result.StartTime.Nanosecond()) / float64(1000000)
-		e.log.Debug("Job Execution done", "timeTakenMs", duration, "ruleId", job.Rule.Id)
-		e.resultQueue <- result
-	}
+	}()
+
+	job.Running = true
+	context := NewAlertResultContext(job.Rule)
+	e.handler.Execute(context)
+	job.Running = false
+
+	e.resultQueue <- context
 }
 
 func (e *Engine) resultHandler() {
@@ -120,25 +100,11 @@ func (e *Engine) resultHandler() {
 	}()
 
 	for result := range e.resultQueue {
-		e.log.Debug("Alert Rule Result", "ruleId", result.AlertJob.Rule.Id, "state", result.State, "retry", result.AlertJob.RetryCount)
-
-		result.AlertJob.Running = false
+		e.log.Debug("Alert Rule Result", "ruleId", result.Rule.Id, "firing", result.Firing)
 
 		if result.Error != nil {
-			result.AlertJob.IncRetry()
-
-			if result.AlertJob.Retryable() {
-				e.log.Error("Alert Rule Result Error", "ruleId", result.AlertJob.Rule.Id, "error", result.Error, "retry", result.AlertJob.RetryCount)
-				e.execQueue <- result.AlertJob
-			} else {
-				e.log.Error("Alert Rule Result Error After Max Retries", "ruleId", result.AlertJob.Rule.Id, "error", result.Error, "retry", result.AlertJob.RetryCount)
-
-				result.State = alertstates.Critical
-				result.Description = fmt.Sprintf("Failed to run check after %d retires, Error: %v", maxAlertExecutionRetries, result.Error)
-				e.responseHandler.Handle(result)
-			}
+			e.log.Error("Alert Rule Result Error", "ruleId", result.Rule.Id, "error", result.Error, "retry")
 		} else {
-			result.AlertJob.ResetRetry()
 			e.responseHandler.Handle(result)
 		}
 	}

+ 41 - 26
pkg/services/alerting/extractor.go

@@ -47,6 +47,17 @@ func (e *DashAlertExtractor) lookupDatasourceId(dsName string) (*m.DataSource, e
 	return nil, errors.New("Could not find datasource id for " + dsName)
 }
 
+func findPanelQueryByRefId(panel *simplejson.Json, refId string) *simplejson.Json {
+	for _, targetsObj := range panel.Get("targets").MustArray() {
+		target := simplejson.NewFromAny(targetsObj)
+
+		if target.Get("refId").MustString() == refId {
+			return target
+		}
+	}
+	return nil
+}
+
 func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
 	e.log.Debug("GetAlerts")
 
@@ -78,34 +89,39 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
 				Handler:     jsonAlert.Get("handler").MustInt64(),
 				Enabled:     jsonAlert.Get("enabled").MustBool(),
 				Description: jsonAlert.Get("description").MustString(),
+				Severity:    m.AlertSeverityType(jsonAlert.Get("severity").MustString()),
 				Frequency:   getTimeDurationStringToSeconds(jsonAlert.Get("frequency").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 datasource, err := e.lookupDatasourceId(dsName); err != nil {
-						return nil, err
-					} else {
-						valueQuery.SetPath([]string{"datasourceId"}, datasource.Id)
-						valueQuery.SetPath([]string{"datasourceType"}, datasource.Type)
-					}
-
-					targetQuery := target.Get("target").MustString()
-					if targetQuery != "" {
-						jsonAlert.SetPath([]string{"query", "query"}, targetQuery)
-					}
+			if !alert.Severity.IsValid() {
+				return nil, AlertValidationError{Reason: "Invalid alert Severity"}
+			}
+
+			for _, condition := range jsonAlert.Get("conditions").MustArray() {
+				jsonCondition := simplejson.NewFromAny(condition)
+
+				jsonQuery := jsonCondition.Get("query")
+				queryRefId := jsonQuery.Get("params").MustArray()[0].(string)
+				panelQuery := findPanelQueryByRefId(panel, queryRefId)
+
+				if panelQuery == nil {
+					return nil, AlertValidationError{Reason: "Alert refes to query that cannot be found"}
+				}
+
+				dsName := ""
+				if panelQuery.Get("datasource").MustString() != "" {
+					dsName = panelQuery.Get("datasource").MustString()
+				} else if panel.Get("datasource").MustString() != "" {
+					dsName = panel.Get("datasource").MustString()
 				}
+
+				if datasource, err := e.lookupDatasourceId(dsName); err != nil {
+					return nil, err
+				} else {
+					jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id)
+				}
+
+				jsonQuery.Set("model", panelQuery.Interface())
 			}
 
 			alert.Settings = jsonAlert
@@ -116,9 +132,8 @@ func (e *DashAlertExtractor) GetAlerts() ([]*m.Alert, error) {
 				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")
+				return nil, err
 			}
-
 		}
 	}
 

+ 73 - 147
pkg/services/alerting/extractor_test.go

@@ -14,162 +14,71 @@ 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": [
+        "id": 57,
+        "title": "Graphite 4",
+        "originalTitle": "Graphite 4",
+        "tags": ["graphite"],
+        "rows": [
         {
-          "title": "Active desktop users",
-          "editable": true,
-          "type": "graph",
-          "id": 3,
-          "targets": [
+          "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",
-						"handler": 1,
-						"enabled": true,
-            "critical": {
-              "value": 20,
-              "op": ">"
-            },
-            "frequency": "60s",
-            "query": {
-              "from": "5m",
-              "refId": "A",
-              "to": "now"
-            },
-            "transform": {
-              "type": "avg",
-              "name": "aggregation"
-            },
-            "warn": {
-              "value": 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",
-						"handler": 0,
-						"enabled": true,
-            "critical": {
-              "value": 20,
-              "op": ">"
-            },
-            "frequency": "60s",
-            "query": {
-              "from": "5m",
-              "refId": "A",
-              "to": "now"
-            },
-            "transform": {
-              "type": "avg",
-              "name": "aggregation"
-            },
-            "warn": {
-              "value": 10,
-              "op": ">"
-            }
-          }
-        }
-      ],
-      "title": "Row"
-    },
-    {
-      "collapse": false,
-      "editable": true,
-      "height": "250px",
-      "panels": [
-        {
-          "datasource": "InfluxDB",
-          "id": 2,
-          "alert": {
-            "name": "name2",
-            "description": "desc2",
-						"enabled": false,
-            "critical": {
-              "level": 20,
-              "op": ">"
-            },
-            "warn": {
-              "level": 10,
-              "op": ">"
+            ],
+            "datasource": null,
+            "alert": {
+              "name": "name1",
+              "description": "desc1",
+              "handler": 1,
+              "enabled": true,
+              "frequency": "60s",
+              "severity": "critical",
+              "conditions": [
+              {
+                "type": "query",
+                "query": {"params": ["A", "5m", "now"]},
+                "reducer": {"type": "avg", "params": []},
+                "evaluator": {"type": ">", "params": [100]}
+              }
+              ]
             }
           },
-          "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": "Active mobile users",
+            "id": 4,
+            "targets": [
+              {"refId": "A", "target": ""},
+              {"refId": "B", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}
+            ],
+            "datasource": "graphite2",
+            "alert": {
+              "name": "name2",
+              "description": "desc2",
+              "handler": 0,
+              "enabled": true,
+              "frequency": "60s",
+              "severity": "warning",
+              "conditions": [
+              {
+                "type": "query",
+                "query":  {"params": ["B", "5m", "now"]},
+                "reducer": {"type": "avg", "params": []},
+                "evaluator": {"type": ">", "params": [100]}
+              }
+              ]
             }
-          ],
-          "title": "Broken influxdb panel",
-          "transform": "table",
-          "type": "table"
+          }
+          ]
         }
-      ],
-      "title": "New row"
-    }
-  ]
-
-}`
+      ]
+    }`
 			dashJson, err := simplejson.NewJson([]byte(json))
 			So(err, ShouldBeNil)
 
@@ -215,6 +124,11 @@ func TestAlertRuleExtraction(t *testing.T) {
 					So(alerts[1].Handler, ShouldEqual, 0)
 				})
 
+				Convey("should extract Severity property", func() {
+					So(alerts[0].Severity, ShouldEqual, "critical")
+					So(alerts[1].Severity, ShouldEqual, "warning")
+				})
+
 				Convey("should extract frequency in seconds", func() {
 					So(alerts[0].Frequency, ShouldEqual, 60)
 					So(alerts[1].Frequency, ShouldEqual, 60)
@@ -231,6 +145,18 @@ func TestAlertRuleExtraction(t *testing.T) {
 					So(alerts[1].Name, ShouldEqual, "name2")
 					So(alerts[1].Description, ShouldEqual, "desc2")
 				})
+
+				Convey("should set datasourceId", func() {
+					condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0])
+					query := condition.Get("query")
+					So(query.Get("datasourceId").MustInt64(), ShouldEqual, 12)
+				})
+
+				Convey("should copy query model to condition", func() {
+					condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0])
+					model := condition.Get("query").Get("model")
+					So(model.Get("target").MustString(), ShouldEqual, "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)")
+				})
 			})
 		})
 	})

+ 124 - 113
pkg/services/alerting/handler.go

@@ -4,11 +4,7 @@ import (
 	"fmt"
 	"time"
 
-	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/log"
-	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/alerting/alertstates"
-	"github.com/grafana/grafana/pkg/tsdb"
 )
 
 var (
@@ -16,133 +12,148 @@ var (
 )
 
 type HandlerImpl struct {
-	log log.Logger
+	log             log.Logger
+	alertJobTimeout time.Duration
 }
 
 func NewHandler() *HandlerImpl {
 	return &HandlerImpl{
-		log: log.New("alerting.executor"),
+		log:             log.New("alerting.handler"),
+		alertJobTimeout: time.Second * 5,
 	}
 }
 
-func (e *HandlerImpl) Execute(job *AlertJob, resultQueue chan *AlertResult) {
-	startTime := time.Now()
+func (e *HandlerImpl) Execute(context *AlertResultContext) {
 
-	timeSeries, err := e.executeQuery(job)
-	if err != nil {
-		resultQueue <- &AlertResult{
-			Error:     err,
-			State:     alertstates.Pending,
-			AlertJob:  job,
-			StartTime: time.Now(),
-			EndTime:   time.Now(),
-		}
-	}
-
-	result := e.evaluateRule(job.Rule, timeSeries)
-	result.AlertJob = job
-	result.StartTime = startTime
-	result.EndTime = time.Now()
-
-	resultQueue <- result
-}
-
-func (e *HandlerImpl) executeQuery(job *AlertJob) (tsdb.TimeSeriesSlice, error) {
-	getDsInfo := &m.GetDataSourceByIdQuery{
-		Id:    job.Rule.Query.DatasourceId,
-		OrgId: job.Rule.OrgId,
-	}
+	go e.eval(context)
 
-	if err := bus.Dispatch(getDsInfo); err != nil {
-		return nil, fmt.Errorf("Could not find datasource")
+	select {
+	case <-time.After(e.alertJobTimeout):
+		context.Error = fmt.Errorf("Timeout")
+		context.EndTime = time.Now()
+		e.log.Debug("Job Execution timeout", "alertId", context.Rule.Id)
+	case <-context.DoneChan:
+		e.log.Debug("Job Execution done", "timeMs", context.GetDurationMs(), "alertId", context.Rule.Id, "firing", context.Firing)
 	}
 
-	req := e.GetRequestForAlertRule(job.Rule, getDsInfo.Result)
-	result := make(tsdb.TimeSeriesSlice, 0)
-
-	resp, err := tsdb.HandleRequest(req)
-	if err != nil {
-		return nil, fmt.Errorf("Alerting: GetSeries() tsdb.HandleRequest() error %v", err)
-	}
-
-	for _, v := range resp.Results {
-		if v.Error != nil {
-			return nil, fmt.Errorf("Alerting: GetSeries() tsdb.HandleRequest() response error %v", v)
-		}
-
-		result = append(result, v.Series...)
-	}
-
-	return result, nil
-}
-
-func (e *HandlerImpl) 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: "-" + rule.Query.From,
-			To:   rule.Query.To,
-		},
-		Queries: []*tsdb.Query{
-			{
-				RefId: "A",
-				Query: rule.Query.Query,
-				DataSource: &tsdb.DataSourceInfo{
-					Id:       datasource.Id,
-					Name:     datasource.Name,
-					PluginId: datasource.Type,
-					Url:      datasource.Url,
-				},
-			},
-		},
-	}
-
-	return req
 }
 
-func (e *HandlerImpl) evaluateRule(rule *AlertRule, series tsdb.TimeSeriesSlice) *AlertResult {
-	e.log.Debug("Evaluating Alerting Rule", "seriesCount", len(series), "ruleName", rule.Name)
-
-	triggeredAlert := make([]*TriggeredAlert, 0)
+func (e *HandlerImpl) eval(context *AlertResultContext) {
 
-	for _, serie := range series {
-		e.log.Debug("Evaluating series", "series", serie.Name)
-		transformedValue, _ := rule.Transformer.Transform(serie)
+	for _, condition := range context.Rule.Conditions {
+		condition.Eval(context)
 
-		critResult := evalCondition(rule.Critical, transformedValue)
-		condition2 := fmt.Sprintf("%v %s %v ", transformedValue, rule.Critical.Operator, rule.Critical.Value)
-		e.log.Debug("Alert execution Crit", "name", serie.Name, "condition", condition2, "result", critResult)
-		if critResult {
-			triggeredAlert = append(triggeredAlert, &TriggeredAlert{
-				State:  alertstates.Critical,
-				Value:  transformedValue,
-				Metric: serie.Name,
-			})
-			continue
+		// break if condition could not be evaluated
+		if context.Error != nil {
+			break
 		}
 
-		warnResult := evalCondition(rule.Warning, transformedValue)
-		condition := fmt.Sprintf("%v %s %v ", transformedValue, rule.Warning.Operator, rule.Warning.Value)
-		e.log.Debug("Alert execution Warn", "name", serie.Name, "condition", condition, "result", warnResult)
-		if warnResult {
-			triggeredAlert = append(triggeredAlert, &TriggeredAlert{
-				State:  alertstates.Warn,
-				Value:  transformedValue,
-				Metric: serie.Name,
-			})
+		// break if result has not triggered yet
+		if context.Firing == false {
+			break
 		}
 	}
 
-	executionState := alertstates.Ok
-	for _, raised := range triggeredAlert {
-		if raised.State == alertstates.Critical {
-			executionState = alertstates.Critical
-		}
-
-		if executionState != alertstates.Critical && raised.State == alertstates.Warn {
-			executionState = alertstates.Warn
-		}
-	}
-
-	return &AlertResult{State: executionState, TriggeredAlerts: triggeredAlert}
+	context.EndTime = time.Now()
+	context.DoneChan <- true
 }
+
+// func (e *HandlerImpl) executeQuery(job *AlertJob) (tsdb.TimeSeriesSlice, error) {
+// 	getDsInfo := &m.GetDataSourceByIdQuery{
+// 		Id:    job.Rule.Query.DatasourceId,
+// 		OrgId: job.Rule.OrgId,
+// 	}
+//
+// 	if err := bus.Dispatch(getDsInfo); err != nil {
+// 		return nil, fmt.Errorf("Could not find datasource")
+// 	}
+//
+// 	req := e.GetRequestForAlertRule(job.Rule, getDsInfo.Result)
+// 	result := make(tsdb.TimeSeriesSlice, 0)
+//
+// 	resp, err := tsdb.HandleRequest(req)
+// 	if err != nil {
+// 		return nil, fmt.Errorf("Alerting: GetSeries() tsdb.HandleRequest() error %v", err)
+// 	}
+//
+// 	for _, v := range resp.Results {
+// 		if v.Error != nil {
+// 			return nil, fmt.Errorf("Alerting: GetSeries() tsdb.HandleRequest() response error %v", v)
+// 		}
+//
+// 		result = append(result, v.Series...)
+// 	}
+//
+// 	return result, nil
+// }
+//
+// func (e *HandlerImpl) 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: "-" + rule.Query.From,
+// 			To:   rule.Query.To,
+// 		},
+// 		Queries: []*tsdb.Query{
+// 			{
+// 				RefId: "A",
+// 				Query: rule.Query.Query,
+// 				DataSource: &tsdb.DataSourceInfo{
+// 					Id:       datasource.Id,
+// 					Name:     datasource.Name,
+// 					PluginId: datasource.Type,
+// 					Url:      datasource.Url,
+// 				},
+// 			},
+// 		},
+// 	}
+//
+// 	return req
+// }
+//
+// func (e *HandlerImpl) evaluateRule(rule *AlertRule, series tsdb.TimeSeriesSlice) *AlertResult {
+// 	e.log.Debug("Evaluating Alerting Rule", "seriesCount", len(series), "ruleName", rule.Name)
+//
+// 	triggeredAlert := make([]*TriggeredAlert, 0)
+//
+// 	for _, serie := range series {
+// 		e.log.Debug("Evaluating series", "series", serie.Name)
+// 		transformedValue, _ := rule.Transformer.Transform(serie)
+//
+// 		critResult := evalCondition(rule.Critical, transformedValue)
+// 		condition2 := fmt.Sprintf("%v %s %v ", transformedValue, rule.Critical.Operator, rule.Critical.Value)
+// 		e.log.Debug("Alert execution Crit", "name", serie.Name, "condition", condition2, "result", critResult)
+// 		if critResult {
+// 			triggeredAlert = append(triggeredAlert, &TriggeredAlert{
+// 				State:  alertstates.Critical,
+// 				Value:  transformedValue,
+// 				Metric: serie.Name,
+// 			})
+// 			continue
+// 		}
+//
+// 		warnResult := evalCondition(rule.Warning, transformedValue)
+// 		condition := fmt.Sprintf("%v %s %v ", transformedValue, rule.Warning.Operator, rule.Warning.Value)
+// 		e.log.Debug("Alert execution Warn", "name", serie.Name, "condition", condition, "result", warnResult)
+// 		if warnResult {
+// 			triggeredAlert = append(triggeredAlert, &TriggeredAlert{
+// 				State:  alertstates.Warn,
+// 				Value:  transformedValue,
+// 				Metric: serie.Name,
+// 			})
+// 		}
+// 	}
+//
+// 	executionState := alertstates.Ok
+// 	for _, raised := range triggeredAlert {
+// 		if raised.State == alertstates.Critical {
+// 			executionState = alertstates.Critical
+// 		}
+//
+// 		if executionState != alertstates.Critical && raised.State == alertstates.Warn {
+// 			executionState = alertstates.Warn
+// 		}
+// 	}
+//
+// 	return &AlertResult{State: executionState, TriggeredAlerts: triggeredAlert}
+// }

+ 144 - 131
pkg/services/alerting/handler_test.go

@@ -3,149 +3,162 @@ package alerting
 import (
 	"testing"
 
-	"github.com/grafana/grafana/pkg/services/alerting/alertstates"
-	"github.com/grafana/grafana/pkg/services/alerting/transformers"
-	"github.com/grafana/grafana/pkg/tsdb"
 	. "github.com/smartystreets/goconvey/convey"
 )
 
-func TestAlertingExecutor(t *testing.T) {
-	Convey("Test alert execution", t, func() {
-		executor := NewHandler()
-
-		Convey("single time serie", func() {
-			Convey("Show return ok since avg is above 2", func() {
-				rule := &AlertRule{
-					Critical:    Level{Value: 10, Operator: ">"},
-					Transformer: transformers.NewAggregationTransformer("avg"),
-				}
-
-				timeSeries := []*tsdb.TimeSeries{
-					tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
-				}
-
-				result := executor.evaluateRule(rule, timeSeries)
-				So(result.State, ShouldEqual, alertstates.Ok)
-			})
-
-			Convey("Show return critical since below 2", func() {
-				rule := &AlertRule{
-					Critical:    Level{Value: 10, Operator: "<"},
-					Transformer: transformers.NewAggregationTransformer("avg"),
-				}
-
-				timeSeries := []*tsdb.TimeSeries{
-					tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
-				}
-
-				result := executor.evaluateRule(rule, timeSeries)
-				So(result.State, ShouldEqual, alertstates.Critical)
-			})
-
-			Convey("Show return critical since sum is above 10", func() {
-				rule := &AlertRule{
-					Critical:    Level{Value: 10, Operator: ">"},
-					Transformer: transformers.NewAggregationTransformer("sum"),
-				}
-
-				timeSeries := []*tsdb.TimeSeries{
-					tsdb.NewTimeSeries("test1", [][2]float64{{9, 0}, {9, 0}}),
-				}
-
-				result := executor.evaluateRule(rule, timeSeries)
-				So(result.State, ShouldEqual, alertstates.Critical)
-			})
-
-			Convey("Show return ok since avg is below 10", func() {
-				rule := &AlertRule{
-					Critical:    Level{Value: 10, Operator: ">"},
-					Transformer: transformers.NewAggregationTransformer("avg"),
-				}
-
-				timeSeries := []*tsdb.TimeSeries{
-					tsdb.NewTimeSeries("test1", [][2]float64{{9, 0}, {9, 0}}),
-				}
-
-				result := executor.evaluateRule(rule, timeSeries)
-				So(result.State, ShouldEqual, alertstates.Ok)
-			})
-
-			Convey("Show return ok since min is below 10", func() {
-				rule := &AlertRule{
-					Critical:    Level{Value: 10, Operator: ">"},
-					Transformer: transformers.NewAggregationTransformer("avg"),
-				}
-
-				timeSeries := []*tsdb.TimeSeries{
-					tsdb.NewTimeSeries("test1", [][2]float64{{11, 0}, {9, 0}}),
-				}
-
-				result := executor.evaluateRule(rule, timeSeries)
-				So(result.State, ShouldEqual, alertstates.Ok)
-			})
+type conditionStub struct {
+	firing bool
+}
 
-			Convey("Show return ok since max is above 10", func() {
-				rule := &AlertRule{
-					Critical:    Level{Value: 10, Operator: ">"},
-					Transformer: transformers.NewAggregationTransformer("max"),
-				}
+func (c *conditionStub) Eval(context *AlertResultContext) {
+	context.Firing = c.firing
+}
 
-				timeSeries := []*tsdb.TimeSeries{
-					tsdb.NewTimeSeries("test1", [][2]float64{{6, 0}, {11, 0}}),
-				}
+func TestAlertingExecutor(t *testing.T) {
+	Convey("Test alert execution", t, func() {
+		handler := NewHandler()
 
-				result := executor.evaluateRule(rule, timeSeries)
-				So(result.State, ShouldEqual, alertstates.Critical)
+		Convey("Show return triggered with single passing condition", func() {
+			context := NewAlertResultContext(&AlertRule{
+				Conditions: []AlertCondition{&conditionStub{
+					firing: true,
+				}},
 			})
 
+			handler.eval(context)
+			So(context.Firing, ShouldEqual, true)
 		})
 
-		Convey("muliple time series", func() {
-			Convey("both are ok", func() {
-				rule := &AlertRule{
-					Critical:    Level{Value: 10, Operator: ">"},
-					Transformer: transformers.NewAggregationTransformer("avg"),
-				}
-
-				timeSeries := []*tsdb.TimeSeries{
-					tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
-					tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
-				}
-
-				result := executor.evaluateRule(rule, timeSeries)
-				So(result.State, ShouldEqual, alertstates.Ok)
-			})
-
-			Convey("first serie is good, second is critical", func() {
-				rule := &AlertRule{
-					Critical:    Level{Value: 10, Operator: ">"},
-					Transformer: transformers.NewAggregationTransformer("avg"),
-				}
-
-				timeSeries := []*tsdb.TimeSeries{
-					tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
-					tsdb.NewTimeSeries("test1", [][2]float64{{11, 0}}),
-				}
-
-				result := executor.evaluateRule(rule, timeSeries)
-				So(result.State, ShouldEqual, alertstates.Critical)
+		Convey("Show return false with not passing condition", func() {
+			context := NewAlertResultContext(&AlertRule{
+				Conditions: []AlertCondition{
+					&conditionStub{firing: true},
+					&conditionStub{firing: false},
+				},
 			})
 
-			Convey("first serie is warn, second is critical", func() {
-				rule := &AlertRule{
-					Critical:    Level{Value: 10, Operator: ">"},
-					Warning:     Level{Value: 5, Operator: ">"},
-					Transformer: transformers.NewAggregationTransformer("avg"),
-				}
-
-				timeSeries := []*tsdb.TimeSeries{
-					tsdb.NewTimeSeries("test1", [][2]float64{{6, 0}}),
-					tsdb.NewTimeSeries("test1", [][2]float64{{11, 0}}),
-				}
-
-				result := executor.evaluateRule(rule, timeSeries)
-				So(result.State, ShouldEqual, alertstates.Critical)
-			})
+			handler.eval(context)
+			So(context.Firing, ShouldEqual, false)
 		})
+
+		// 	Convey("Show return critical since below 2", func() {
+		// 		rule := &AlertRule{
+		// 			Critical:    Level{Value: 10, Operator: "<"},
+		// 			Transformer: transformers.NewAggregationTransformer("avg"),
+		// 		}
+		//
+		// 		timeSeries := []*tsdb.TimeSeries{
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
+		// 		}
+		//
+		// 		result := executor.evaluateRule(rule, timeSeries)
+		// 		So(result.State, ShouldEqual, alertstates.Critical)
+		// 	})
+		//
+		// 	Convey("Show return critical since sum is above 10", func() {
+		// 		rule := &AlertRule{
+		// 			Critical:    Level{Value: 10, Operator: ">"},
+		// 			Transformer: transformers.NewAggregationTransformer("sum"),
+		// 		}
+		//
+		// 		timeSeries := []*tsdb.TimeSeries{
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{9, 0}, {9, 0}}),
+		// 		}
+		//
+		// 		result := executor.evaluateRule(rule, timeSeries)
+		// 		So(result.State, ShouldEqual, alertstates.Critical)
+		// 	})
+		//
+		// 	Convey("Show return ok since avg is below 10", func() {
+		// 		rule := &AlertRule{
+		// 			Critical:    Level{Value: 10, Operator: ">"},
+		// 			Transformer: transformers.NewAggregationTransformer("avg"),
+		// 		}
+		//
+		// 		timeSeries := []*tsdb.TimeSeries{
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{9, 0}, {9, 0}}),
+		// 		}
+		//
+		// 		result := executor.evaluateRule(rule, timeSeries)
+		// 		So(result.State, ShouldEqual, alertstates.Ok)
+		// 	})
+		//
+		// 	Convey("Show return ok since min is below 10", func() {
+		// 		rule := &AlertRule{
+		// 			Critical:    Level{Value: 10, Operator: ">"},
+		// 			Transformer: transformers.NewAggregationTransformer("avg"),
+		// 		}
+		//
+		// 		timeSeries := []*tsdb.TimeSeries{
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{11, 0}, {9, 0}}),
+		// 		}
+		//
+		// 		result := executor.evaluateRule(rule, timeSeries)
+		// 		So(result.State, ShouldEqual, alertstates.Ok)
+		// 	})
+		//
+		// 	Convey("Show return ok since max is above 10", func() {
+		// 		rule := &AlertRule{
+		// 			Critical:    Level{Value: 10, Operator: ">"},
+		// 			Transformer: transformers.NewAggregationTransformer("max"),
+		// 		}
+		//
+		// 		timeSeries := []*tsdb.TimeSeries{
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{6, 0}, {11, 0}}),
+		// 		}
+		//
+		// 		result := executor.evaluateRule(rule, timeSeries)
+		// 		So(result.State, ShouldEqual, alertstates.Critical)
+		// 	})
+		//
+		// })
+		//
+		// Convey("muliple time series", func() {
+		// 	Convey("both are ok", func() {
+		// 		rule := &AlertRule{
+		// 			Critical:    Level{Value: 10, Operator: ">"},
+		// 			Transformer: transformers.NewAggregationTransformer("avg"),
+		// 		}
+		//
+		// 		timeSeries := []*tsdb.TimeSeries{
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
+		// 		}
+		//
+		// 		result := executor.evaluateRule(rule, timeSeries)
+		// 		So(result.State, ShouldEqual, alertstates.Ok)
+		// 	})
+		//
+		// 	Convey("first serie is good, second is critical", func() {
+		// 		rule := &AlertRule{
+		// 			Critical:    Level{Value: 10, Operator: ">"},
+		// 			Transformer: transformers.NewAggregationTransformer("avg"),
+		// 		}
+		//
+		// 		timeSeries := []*tsdb.TimeSeries{
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{2, 0}}),
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{11, 0}}),
+		// 		}
+		//
+		// 		result := executor.evaluateRule(rule, timeSeries)
+		// 		So(result.State, ShouldEqual, alertstates.Critical)
+		// 	})
+		//
+		// 	Convey("first serie is warn, second is critical", func() {
+		// 		rule := &AlertRule{
+		// 			Critical:    Level{Value: 10, Operator: ">"},
+		// 			Warning:     Level{Value: 5, Operator: ">"},
+		// 			Transformer: transformers.NewAggregationTransformer("avg"),
+		// 		}
+		//
+		// 		timeSeries := []*tsdb.TimeSeries{
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{6, 0}}),
+		// 			tsdb.NewTimeSeries("test1", [][2]float64{{11, 0}}),
+		// 		}
+		//
+		// 		result := executor.evaluateRule(rule, timeSeries)
+		// 		So(result.State, ShouldEqual, alertstates.Critical)
+		// 	})
+		// })
 	})
 }

+ 21 - 4
pkg/services/alerting/interfaces.go

@@ -1,9 +1,13 @@
 package alerting
 
-import "time"
+import (
+	"time"
 
-type AlertingHandler interface {
-	Execute(rule *AlertJob, resultChan chan *AlertResult)
+	"github.com/grafana/grafana/pkg/tsdb"
+)
+
+type AlertHandler interface {
+	Execute(context *AlertResultContext)
 }
 
 type Scheduler interface {
@@ -12,5 +16,18 @@ type Scheduler interface {
 }
 
 type Notifier interface {
-	Notify(alertResult *AlertResult)
+	Notify(alertResult *AlertResultContext)
+	GetType() string
+}
+
+type AlertCondition interface {
+	Eval(result *AlertResultContext)
+}
+
+type QueryReducer interface {
+	Reduce(timeSeries *tsdb.TimeSeries) float64
+}
+
+type AlertEvaluator interface {
+	Eval(timeSeries *tsdb.TimeSeries, reducedValue float64) bool
 }

+ 41 - 11
pkg/services/alerting/models.go

@@ -1,6 +1,11 @@
 package alerting
 
-import "time"
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/log"
+)
 
 type AlertJob struct {
 	Offset     int64
@@ -22,18 +27,43 @@ func (aj *AlertJob) IncRetry() {
 	aj.RetryCount++
 }
 
-type AlertResult struct {
-	State           string
-	TriggeredAlerts []*TriggeredAlert
-	Error           error
-	Description     string
-	StartTime       time.Time
-	EndTime         time.Time
+type AlertResultContext struct {
+	Firing      bool
+	IsTestRun   bool
+	Events      []*AlertEvent
+	Logs        []*AlertResultLogEntry
+	Error       error
+	Description string
+	StartTime   time.Time
+	EndTime     time.Time
+	Rule        *AlertRule
+	DoneChan    chan bool
+	CancelChan  chan bool
+	log         log.Logger
+}
+
+func (a *AlertResultContext) GetDurationMs() float64 {
+	return float64(a.EndTime.Nanosecond()-a.StartTime.Nanosecond()) / float64(1000000)
+}
+
+func NewAlertResultContext(rule *AlertRule) *AlertResultContext {
+	return &AlertResultContext{
+		StartTime:  time.Now(),
+		Rule:       rule,
+		Logs:       make([]*AlertResultLogEntry, 0),
+		Events:     make([]*AlertEvent, 0),
+		DoneChan:   make(chan bool, 1),
+		CancelChan: make(chan bool, 1),
+		log:        log.New("alerting.engine"),
+	}
+}
 
-	AlertJob *AlertJob
+type AlertResultLogEntry struct {
+	Message string
+	Data    interface{}
 }
 
-type TriggeredAlert struct {
+type AlertEvent struct {
 	Value  float64
 	Metric string
 	State  string
@@ -46,7 +76,7 @@ type Level struct {
 }
 
 type AlertQuery struct {
-	Query        string
+	Model        *simplejson.Json
 	DatasourceId int64
 	From         string
 	To           string

+ 107 - 154
pkg/services/alerting/notifier.go

@@ -1,207 +1,160 @@
 package alerting
 
 import (
+	"errors"
 	"fmt"
-	"strconv"
+	"strings"
 
 	"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"
-	"github.com/grafana/grafana/pkg/services/alerting/alertstates"
 	"github.com/grafana/grafana/pkg/setting"
 )
 
-type NotifierImpl struct {
-	log              log.Logger
-	getNotifications func(orgId int64, notificationGroups []int64) []*Notification
+type RootNotifier struct {
+	NotifierBase
+	log log.Logger
 }
 
-func NewNotifier() *NotifierImpl {
-	log := log.New("alerting.notifier")
-	return &NotifierImpl{
-		log:              log,
-		getNotifications: buildGetNotifiers(log),
+func NewRootNotifier() *RootNotifier {
+	return &RootNotifier{
+		log: log.New("alerting.notifier"),
 	}
 }
 
-func (n NotifierImpl) ShouldDispath(alertResult *AlertResult, notifier *Notification) bool {
-	warn := alertResult.State == alertstates.Warn && notifier.SendWarning
-	crit := alertResult.State == alertstates.Critical && notifier.SendCritical
-	return (warn || crit) || alertResult.State == alertstates.Ok
-}
+func (n *RootNotifier) Notify(context *AlertResultContext) {
+	n.log.Info("Sending notifications for", "ruleId", context.Rule.Id)
 
-func (n *NotifierImpl) Notify(alertResult *AlertResult) {
-	notifiers := n.getNotifications(alertResult.AlertJob.Rule.OrgId, alertResult.AlertJob.Rule.NotificationGroups)
+	notifiers, err := n.getNotifiers(context.Rule.OrgId, context.Rule.Notifications)
+	if err != nil {
+		n.log.Error("Failed to read notifications", "error", err)
+		return
+	}
 
 	for _, notifier := range notifiers {
-		if n.ShouldDispath(alertResult, notifier) {
-			n.log.Info("Sending notification", "state", alertResult.State, "type", notifier.Type)
-			go notifier.Notifierr.Dispatch(alertResult)
+		n.log.Info("Sending notification", "firing", context.Firing, "type", notifier.GetType())
+		go notifier.Notify(context)
+	}
+}
+
+func (n *RootNotifier) getNotifiers(orgId int64, notificationIds []int64) ([]Notifier, error) {
+	query := &m.GetAlertNotificationsQuery{OrgId: orgId, Ids: notificationIds}
+
+	if err := bus.Dispatch(query); err != nil {
+		return nil, err
+	}
+
+	var result []Notifier
+	for _, notification := range query.Result {
+		if not, err := NewNotificationFromDBModel(notification); err != nil {
+			return nil, err
+		} else {
+			result = append(result, not)
 		}
 	}
+
+	return result, nil
 }
 
-type Notification struct {
-	Name         string
-	Type         string
-	SendWarning  bool
-	SendCritical bool
+type NotifierBase struct {
+	Name string
+	Type string
+}
 
-	Notifierr NotificationDispatcher
+func (n *NotifierBase) GetType() string {
+	return n.Type
 }
 
 type EmailNotifier struct {
-	To  string
-	log log.Logger
+	NotifierBase
+	Addresses []string
+	log       log.Logger
 }
 
-func (this *EmailNotifier) Dispatch(alertResult *AlertResult) {
-	this.log.Info("Sending email")
-	grafanaUrl := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort)
-	if setting.AppSubUrl != "" {
-		grafanaUrl += "/" + setting.AppSubUrl
-	}
+func (this *EmailNotifier) Notify(context *AlertResultContext) {
+	this.log.Info("Sending alert notification to", "addresses", this.Addresses)
 
-	query := &m.GetDashboardsQuery{
-		DashboardIds: []int64{alertResult.AlertJob.Rule.DashboardId},
-	}
-
-	if err := bus.Dispatch(query); err != nil {
+	slugQuery := &m.GetDashboardSlugByIdQuery{Id: context.Rule.DashboardId}
+	if err := bus.Dispatch(slugQuery); err != nil {
 		this.log.Error("Failed to load dashboard", "error", err)
 		return
 	}
 
-	if len(query.Result) != 1 {
-		this.log.Error("Can only support one dashboard", "result", len(query.Result))
-		return
-	}
-
-	dashboard := query.Result[0]
-
-	panelId := strconv.Itoa(int(alertResult.AlertJob.Rule.PanelId))
+	ruleLink := fmt.Sprintf("%sdashboard/db/%s?fullscreen&edit&tab=alert&panelId=%d", setting.AppUrl, slugQuery.Result, context.Rule.PanelId)
 
-	//TODO: get from alertrule and transforms to seconds
-	from := "1466169458375"
-	to := "1466171258375"
-
-	renderUrl := fmt.Sprintf("%s/render/dashboard-solo/db/%s?from=%s&to=%s&panelId=%s&width=1000&height=500", grafanaUrl, dashboard.Slug, from, to, panelId)
 	cmd := &m.SendEmailCommand{
 		Data: map[string]interface{}{
-			"Name":            "Name",
-			"State":           alertResult.State,
-			"Description":     alertResult.Description,
-			"TriggeredAlerts": alertResult.TriggeredAlerts,
-			"DashboardLink":   grafanaUrl + "/dashboard/db/" + dashboard.Slug,
-			"AlertPageUrl":    grafanaUrl + "/alerting",
-			"DashboardImage":  renderUrl,
+			"RuleState": context.Rule.State,
+			"RuleName":  context.Rule.Name,
+			"Severity":  context.Rule.Severity,
+			"RuleLink":  ruleLink,
 		},
-		To:       []string{this.To},
+		To:       this.Addresses,
 		Template: "alert_notification.html",
 	}
 
 	err := bus.Dispatch(cmd)
 	if err != nil {
-		this.log.Error("Could not send alert notification as email", "error", err)
-	}
-}
-
-type WebhookNotifier struct {
-	Url      string
-	User     string
-	Password string
-	log      log.Logger
-}
-
-func (this *WebhookNotifier) Dispatch(alertResult *AlertResult) {
-	this.log.Info("Sending webhook")
-
-	bodyJSON := simplejson.New()
-	bodyJSON.Set("name", alertResult.AlertJob.Rule.Name)
-	bodyJSON.Set("state", alertResult.State)
-	bodyJSON.Set("trigged", alertResult.TriggeredAlerts)
-
-	body, _ := bodyJSON.MarshalJSON()
-
-	cmd := &m.SendWebhook{
-		Url:      this.Url,
-		User:     this.User,
-		Password: this.Password,
-		Body:     string(body),
-	}
-
-	bus.Dispatch(cmd)
-}
-
-type NotificationDispatcher interface {
-	Dispatch(alertResult *AlertResult)
-}
-
-func buildGetNotifiers(log log.Logger) func(orgId int64, notificationGroups []int64) []*Notification {
-	return func(orgId int64, notificationGroups []int64) []*Notification {
-		query := &m.GetAlertNotificationQuery{
-			OrgID:                orgId,
-			Ids:                  notificationGroups,
-			IncludeAlwaysExecute: true,
-		}
-		err := bus.Dispatch(query)
-		if err != nil {
-			log.Error("Failed to read notifications", "error", err)
-		}
-
-		var result []*Notification
-		for _, notification := range query.Result {
-			not, err := NewNotificationFromDBModel(notification)
-			if err == nil {
-				result = append(result, not)
-			} else {
-				log.Error("Failed to read notification model", "error", err)
-			}
-		}
-
-		return result
-	}
-}
-
-func NewNotificationFromDBModel(model *m.AlertNotification) (*Notification, error) {
-	notifier, err := createNotifier(model.Type, model.Settings)
-
-	if err != nil {
-		return nil, err
+		this.log.Error("Failed to send alert notification email", "error", err)
 	}
-
-	return &Notification{
-		Name:         model.Name,
-		Type:         model.Type,
-		Notifierr:    notifier,
-		SendCritical: model.Settings.Get("sendCrit").MustBool(),
-		SendWarning:  model.Settings.Get("sendWarn").MustBool(),
-	}, nil
 }
 
-var createNotifier = func(notificationType string, settings *simplejson.Json) (NotificationDispatcher, error) {
-	if notificationType == "email" {
-		to := settings.Get("to").MustString()
-
-		if to == "" {
-			return nil, fmt.Errorf("Could not find to propertie in settings")
+// type WebhookNotifier struct {
+// 	Url      string
+// 	User     string
+// 	Password string
+// 	log      log.Logger
+// }
+//
+// func (this *WebhookNotifier) Dispatch(context *AlertResultContext) {
+// 	this.log.Info("Sending webhook")
+//
+// 	bodyJSON := simplejson.New()
+// 	bodyJSON.Set("name", context.AlertJob.Rule.Name)
+// 	bodyJSON.Set("state", context.State)
+// 	bodyJSON.Set("trigged", context.TriggeredAlerts)
+//
+// 	body, _ := bodyJSON.MarshalJSON()
+//
+// 	cmd := &m.SendWebhook{
+// 		Url:      this.Url,
+// 		User:     this.User,
+// 		Password: this.Password,
+// 		Body:     string(body),
+// 	}
+//
+// 	bus.Dispatch(cmd)
+// }
+
+func NewNotificationFromDBModel(model *m.AlertNotification) (Notifier, error) {
+	if model.Type == "email" {
+		addressesString := model.Settings.Get("addresses").MustString()
+
+		if addressesString == "" {
+			return nil, fmt.Errorf("Could not find addresses in settings")
 		}
 
 		return &EmailNotifier{
-			To:  to,
-			log: log.New("alerting.notification.email"),
+			NotifierBase: NotifierBase{
+				Name: model.Name,
+				Type: model.Type,
+			},
+			Addresses: strings.Split(addressesString, "\n"),
+			log:       log.New("alerting.notification.email"),
 		}, nil
 	}
 
-	url := settings.Get("url").MustString()
-	if url == "" {
-		return nil, fmt.Errorf("Could not find url propertie in settings")
-	}
-
-	return &WebhookNotifier{
-		Url:      url,
-		User:     settings.Get("user").MustString(),
-		Password: settings.Get("password").MustString(),
-		log:      log.New("alerting.notification.webhook"),
-	}, nil
+	return nil, errors.New("Unsupported notification type")
+
+	// url := settings.Get("url").MustString()
+	// if url == "" {
+	// 	return nil, fmt.Errorf("Could not find url propertie in settings")
+	// }
+	//
+	// return &WebhookNotifier{
+	// 	Url:      url,
+	// 	User:     settings.Get("user").MustString(),
+	// 	Password: settings.Get("password").MustString(),
+	// 	log:      log.New("alerting.notification.webhook"),
+	// }, nil
 }

+ 112 - 123
pkg/services/alerting/notifier_test.go

@@ -1,125 +1,114 @@
 package alerting
 
-import (
-	"testing"
-
-	"reflect"
-
-	"github.com/grafana/grafana/pkg/components/simplejson"
-	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/alerting/alertstates"
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-func TestAlertNotificationExtraction(t *testing.T) {
-	Convey("Notifier tests", t, func() {
-		Convey("rules for sending notifications", func() {
-			dummieNotifier := NotifierImpl{}
-
-			result := &AlertResult{
-				State: alertstates.Critical,
-			}
-
-			notifier := &Notification{
-				Name:         "Test Notifier",
-				Type:         "TestType",
-				SendCritical: true,
-				SendWarning:  true,
-			}
-
-			Convey("Should send notification", func() {
-				So(dummieNotifier.ShouldDispath(result, notifier), ShouldBeTrue)
-			})
-
-			Convey("warn:false and state:warn should not send", func() {
-				result.State = alertstates.Warn
-				notifier.SendWarning = false
-				So(dummieNotifier.ShouldDispath(result, notifier), ShouldBeFalse)
-			})
-		})
-
-		Convey("Parsing alert notification from settings", func() {
-			Convey("Parsing email", func() {
-				Convey("empty settings should return error", func() {
-					json := `{ }`
-
-					settingsJSON, _ := simplejson.NewJson([]byte(json))
-					model := &m.AlertNotification{
-						Name:     "ops",
-						Type:     "email",
-						Settings: settingsJSON,
-					}
-
-					_, err := NewNotificationFromDBModel(model)
-					So(err, ShouldNotBeNil)
-				})
-
-				Convey("from settings", func() {
-					json := `
-				{
-					"to": "ops@grafana.org"
-				}`
-
-					settingsJSON, _ := simplejson.NewJson([]byte(json))
-					model := &m.AlertNotification{
-						Name:     "ops",
-						Type:     "email",
-						Settings: settingsJSON,
-					}
-
-					not, err := NewNotificationFromDBModel(model)
-
-					So(err, ShouldBeNil)
-					So(not.Name, ShouldEqual, "ops")
-					So(not.Type, ShouldEqual, "email")
-					So(reflect.TypeOf(not.Notifierr).Elem().String(), ShouldEqual, "alerting.EmailNotifier")
-
-					email := not.Notifierr.(*EmailNotifier)
-					So(email.To, ShouldEqual, "ops@grafana.org")
-				})
-			})
-
-			Convey("Parsing webhook", func() {
-				Convey("empty settings should return error", func() {
-					json := `{ }`
-
-					settingsJSON, _ := simplejson.NewJson([]byte(json))
-					model := &m.AlertNotification{
-						Name:     "ops",
-						Type:     "webhook",
-						Settings: settingsJSON,
-					}
-
-					_, err := NewNotificationFromDBModel(model)
-					So(err, ShouldNotBeNil)
-				})
-
-				Convey("from settings", func() {
-					json := `
-				{
-					"url": "http://localhost:3000",
-					"username": "username",
-					"password": "password"
-				}`
-
-					settingsJSON, _ := simplejson.NewJson([]byte(json))
-					model := &m.AlertNotification{
-						Name:     "slack",
-						Type:     "webhook",
-						Settings: settingsJSON,
-					}
-
-					not, err := NewNotificationFromDBModel(model)
-
-					So(err, ShouldBeNil)
-					So(not.Name, ShouldEqual, "slack")
-					So(not.Type, ShouldEqual, "webhook")
-					So(reflect.TypeOf(not.Notifierr).Elem().String(), ShouldEqual, "alerting.WebhookNotifier")
-
-					webhook := not.Notifierr.(*WebhookNotifier)
-					So(webhook.Url, ShouldEqual, "http://localhost:3000")
-				})
-			})
-		})
-	})
-}
+// func TestAlertNotificationExtraction(t *testing.T) {
+// 	Convey("Notifier tests", t, func() {
+// 		Convey("rules for sending notifications", func() {
+// 			dummieNotifier := NotifierImpl{}
+//
+// 			result := &AlertResult{
+// 				State: alertstates.Critical,
+// 			}
+//
+// 			notifier := &Notification{
+// 				Name:         "Test Notifier",
+// 				Type:         "TestType",
+// 				SendCritical: true,
+// 				SendWarning:  true,
+// 			}
+//
+// 			Convey("Should send notification", func() {
+// 				So(dummieNotifier.ShouldDispath(result, notifier), ShouldBeTrue)
+// 			})
+//
+// 			Convey("warn:false and state:warn should not send", func() {
+// 				result.State = alertstates.Warn
+// 				notifier.SendWarning = false
+// 				So(dummieNotifier.ShouldDispath(result, notifier), ShouldBeFalse)
+// 			})
+// 		})
+//
+// 		Convey("Parsing alert notification from settings", func() {
+// 			Convey("Parsing email", func() {
+// 				Convey("empty settings should return error", func() {
+// 					json := `{ }`
+//
+// 					settingsJSON, _ := simplejson.NewJson([]byte(json))
+// 					model := &m.AlertNotification{
+// 						Name:     "ops",
+// 						Type:     "email",
+// 						Settings: settingsJSON,
+// 					}
+//
+// 					_, err := NewNotificationFromDBModel(model)
+// 					So(err, ShouldNotBeNil)
+// 				})
+//
+// 				Convey("from settings", func() {
+// 					json := `
+// 				{
+// 					"to": "ops@grafana.org"
+// 				}`
+//
+// 					settingsJSON, _ := simplejson.NewJson([]byte(json))
+// 					model := &m.AlertNotification{
+// 						Name:     "ops",
+// 						Type:     "email",
+// 						Settings: settingsJSON,
+// 					}
+//
+// 					not, err := NewNotificationFromDBModel(model)
+//
+// 					So(err, ShouldBeNil)
+// 					So(not.Name, ShouldEqual, "ops")
+// 					So(not.Type, ShouldEqual, "email")
+// 					So(reflect.TypeOf(not.Notifierr).Elem().String(), ShouldEqual, "alerting.EmailNotifier")
+//
+// 					email := not.Notifierr.(*EmailNotifier)
+// 					So(email.To, ShouldEqual, "ops@grafana.org")
+// 				})
+// 			})
+//
+// 			Convey("Parsing webhook", func() {
+// 				Convey("empty settings should return error", func() {
+// 					json := `{ }`
+//
+// 					settingsJSON, _ := simplejson.NewJson([]byte(json))
+// 					model := &m.AlertNotification{
+// 						Name:     "ops",
+// 						Type:     "webhook",
+// 						Settings: settingsJSON,
+// 					}
+//
+// 					_, err := NewNotificationFromDBModel(model)
+// 					So(err, ShouldNotBeNil)
+// 				})
+//
+// 				Convey("from settings", func() {
+// 					json := `
+// 				{
+// 					"url": "http://localhost:3000",
+// 					"username": "username",
+// 					"password": "password"
+// 				}`
+//
+// 					settingsJSON, _ := simplejson.NewJson([]byte(json))
+// 					model := &m.AlertNotification{
+// 						Name:     "slack",
+// 						Type:     "webhook",
+// 						Settings: settingsJSON,
+// 					}
+//
+// 					not, err := NewNotificationFromDBModel(model)
+//
+// 					So(err, ShouldBeNil)
+// 					So(not.Name, ShouldEqual, "slack")
+// 					So(not.Type, ShouldEqual, "webhook")
+// 					So(reflect.TypeOf(not.Notifierr).Elem().String(), ShouldEqual, "alerting.WebhookNotifier")
+//
+// 					webhook := not.Notifierr.(*WebhookNotifier)
+// 					So(webhook.Url, ShouldEqual, "http://localhost:3000")
+// 				})
+// 			})
+// 		})
+// 	})
+// }

+ 13 - 8
pkg/services/alerting/reader.go

@@ -18,10 +18,13 @@ type AlertRuleReader struct {
 	serverID       string
 	serverPosition int
 	clusterSize    int
+	log            log.Logger
 }
 
 func NewRuleReader() *AlertRuleReader {
-	ruleReader := &AlertRuleReader{}
+	ruleReader := &AlertRuleReader{
+		log: log.New("alerting.ruleReader"),
+	}
 
 	go ruleReader.initReader()
 	return ruleReader
@@ -40,17 +43,19 @@ func (arr *AlertRuleReader) initReader() {
 
 func (arr *AlertRuleReader) Fetch() []*AlertRule {
 	cmd := &m.GetAllAlertsQuery{}
-	err := bus.Dispatch(cmd)
 
-	if err != nil {
-		log.Error(1, "Alerting: ruleReader.fetch(): Could not load alerts", err)
+	if err := bus.Dispatch(cmd); err != nil {
+		arr.log.Error("Could not load alerts", "error", err)
 		return []*AlertRule{}
 	}
 
-	res := make([]*AlertRule, len(cmd.Result))
-	for i, ruleDef := range cmd.Result {
-		model, _ := NewAlertRuleFromDBModel(ruleDef)
-		res[i] = model
+	res := make([]*AlertRule, 0)
+	for _, ruleDef := range cmd.Result {
+		if model, err := NewAlertRuleFromDBModel(ruleDef); err != nil {
+			arr.log.Error("Could not build alert model for rule", "ruleId", ruleDef.Id, "error", err)
+		} else {
+			res = append(res, model)
+		}
 	}
 
 	return res

+ 23 - 38
pkg/services/alerting/result_handler.go

@@ -1,16 +1,13 @@
 package alerting
 
 import (
-	"time"
-
 	"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 ResultHandler interface {
-	Handle(result *AlertResult)
+	Handle(result *AlertResultContext)
 }
 
 type ResultHandlerImpl struct {
@@ -20,49 +17,37 @@ type ResultHandlerImpl struct {
 
 func NewResultHandler() *ResultHandlerImpl {
 	return &ResultHandlerImpl{
-		log:      log.New("alerting.responseHandler"),
-		notifier: NewNotifier(),
+		log:      log.New("alerting.resultHandler"),
+		notifier: NewRootNotifier(),
 	}
 }
 
-func (handler *ResultHandlerImpl) Handle(result *AlertResult) {
-	if handler.shouldUpdateState(result) {
-		cmd := &m.UpdateAlertStateCommand{
-			AlertId:         result.AlertJob.Rule.Id,
-			State:           result.State,
-			Info:            result.Description,
-			OrgId:           result.AlertJob.Rule.OrgId,
-			TriggeredAlerts: simplejson.NewFromAny(result.TriggeredAlerts),
+func (handler *ResultHandlerImpl) Handle(result *AlertResultContext) {
+	var newState m.AlertStateType
+
+	if result.Error != nil {
+		handler.log.Error("Alert Rule Result Error", "ruleId", result.Rule.Id, "error", result.Error)
+		newState = m.AlertStatePending
+	} else if result.Firing {
+		newState = m.AlertStateFiring
+	} else {
+		newState = m.AlertStateOK
+	}
+
+	if result.Rule.State != newState {
+		handler.log.Info("New state change", "alertId", result.Rule.Id, "newState", newState, "oldState", result.Rule.State)
+
+		cmd := &m.SetAlertStateCommand{
+			AlertId: result.Rule.Id,
+			OrgId:   result.Rule.OrgId,
+			State:   newState,
 		}
 
 		if err := bus.Dispatch(cmd); err != nil {
 			handler.log.Error("Failed to save state", "error", err)
 		}
 
-		handler.log.Debug("will notify about new state", "new state", result.State)
+		result.Rule.State = newState
 		handler.notifier.Notify(result)
 	}
 }
-
-func (handler *ResultHandlerImpl) shouldUpdateState(result *AlertResult) bool {
-	query := &m.GetLastAlertStateQuery{
-		AlertId: result.AlertJob.Rule.Id,
-		OrgId:   result.AlertJob.Rule.OrgId,
-	}
-
-	if err := bus.Dispatch(query); err != nil {
-		log.Error2("Failed to read last alert state", "error", err)
-		return false
-	}
-
-	if query.Result == nil {
-		return true
-	}
-
-	lastExecution := query.Result.Created
-	asdf := result.StartTime.Add(time.Minute * -15)
-	olderThen15Min := lastExecution.Before(asdf)
-	changedState := query.Result.State != result.State
-
-	return changedState || olderThen15Min
-}

+ 56 - 57
pkg/services/alerting/result_handler_test.go

@@ -1,59 +1,58 @@
 package alerting
 
-import (
-	"testing"
-	"time"
-
-	"github.com/grafana/grafana/pkg/bus"
-	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/services/alerting/alertstates"
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-func TestAlertResultHandler(t *testing.T) {
-	Convey("Test result Handler", t, func() {
-		resultHandler := ResultHandlerImpl{}
-		mockResult := &AlertResult{
-			State: alertstates.Ok,
-			AlertJob: &AlertJob{
-				Rule: &AlertRule{
-					Id:    1,
-					OrgId: 1,
-				},
-			},
-		}
-		mockAlertState := &m.AlertState{}
-		bus.ClearBusHandlers()
-		bus.AddHandler("test", func(query *m.GetLastAlertStateQuery) error {
-			query.Result = mockAlertState
-			return nil
-		})
-
-		Convey("Should update", func() {
-
-			Convey("when no earlier alert state", func() {
-				mockAlertState = nil
-				So(resultHandler.shouldUpdateState(mockResult), ShouldBeTrue)
-			})
-
-			Convey("alert state have changed", func() {
-				mockAlertState = &m.AlertState{
-					NewState: alertstates.Critical,
-				}
-				mockResult.State = alertstates.Ok
-				So(resultHandler.shouldUpdateState(mockResult), ShouldBeTrue)
-			})
-
-			Convey("last alert state was 15min ago", func() {
-				now := time.Now()
-				mockAlertState = &m.AlertState{
-					NewState: alertstates.Critical,
-					Created:  now.Add(time.Minute * -30),
-				}
-				mockResult.State = alertstates.Critical
-				mockResult.ExeuctionTime = time.Now()
-				So(resultHandler.shouldUpdateState(mockResult), ShouldBeTrue)
-			})
-		})
-	})
-}
+// import (
+// 	"testing"
+// 	"time"
+//
+// 	"github.com/grafana/grafana/pkg/bus"
+// 	m "github.com/grafana/grafana/pkg/models"
+// 	"github.com/grafana/grafana/pkg/services/alerting/alertstates"
+//
+// 	. "github.com/smartystreets/goconvey/convey"
+// )
+//
+// func TestAlertResultHandler(t *testing.T) {
+// 	Convey("Test result Handler", t, func() {
+// 		resultHandler := ResultHandlerImpl{}
+// 		mockResult := &AlertResultContext{
+// 			Triggered: false,
+// 			Rule: &AlertRule{
+// 				Id:    1,
+// 				OrgId 1,
+// 			},
+// 		}
+// 		mockAlertState := &m.AlertState{}
+// 		bus.ClearBusHandlers()
+// 		bus.AddHandler("test", func(query *m.GetLastAlertStateQuery) error {
+// 			query.Result = mockAlertState
+// 			return nil
+// 		})
+//
+// 		Convey("Should update", func() {
+//
+// 			Convey("when no earlier alert state", func() {
+// 				mockAlertState = nil
+// 				So(resultHandler.shouldUpdateState(mockResult), ShouldBeTrue)
+// 			})
+//
+// 			Convey("alert state have changed", func() {
+// 				mockAlertState = &m.AlertState{
+// 					State: alertstates.Critical,
+// 				}
+// 				mockResult.Triggered = false
+// 				So(resultHandler.shouldUpdateState(mockResult), ShouldBeTrue)
+// 			})
+//
+// 			Convey("last alert state was 15min ago", func() {
+// 				now := time.Now()
+// 				mockAlertState = &m.AlertState{
+// 					State:   alertstates.Critical,
+// 					Created: now.Add(time.Minute * -30),
+// 				}
+// 				mockResult.Triggered = true
+// 				mockResult.StartTime = time.Now()
+// 				So(resultHandler.shouldUpdateState(mockResult), ShouldBeTrue)
+// 			})
+// 		})
+// 	})
+// }

+ 57 - 0
pkg/services/alerting/test_rule.go

@@ -0,0 +1,57 @@
+package alerting
+
+import (
+	"fmt"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+type AlertTestCommand struct {
+	Dashboard *simplejson.Json
+	PanelId   int64
+	OrgId     int64
+
+	Result *AlertResultContext
+}
+
+func init() {
+	bus.AddHandler("alerting", handleAlertTestCommand)
+}
+
+func handleAlertTestCommand(cmd *AlertTestCommand) error {
+
+	dash := m.NewDashboardFromJson(cmd.Dashboard)
+
+	extractor := NewDashAlertExtractor(dash, cmd.OrgId)
+	alerts, err := extractor.GetAlerts()
+	if err != nil {
+		return err
+	}
+
+	for _, alert := range alerts {
+		if alert.PanelId == cmd.PanelId {
+			rule, err := NewAlertRuleFromDBModel(alert)
+			if err != nil {
+				return err
+			}
+
+			cmd.Result = testAlertRule(rule)
+			return nil
+		}
+	}
+
+	return fmt.Errorf("Could not find alert with panel id %d", cmd.PanelId)
+}
+
+func testAlertRule(rule *AlertRule) *AlertResultContext {
+	handler := NewHandler()
+
+	context := NewAlertResultContext(rule)
+	context.IsTestRun = true
+
+	handler.Execute(context)
+
+	return context
+}

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

@@ -1,71 +0,0 @@
-package transformers
-
-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")
-}

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

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

+ 19 - 45
pkg/services/sqlstore/alert.go

@@ -17,52 +17,9 @@ func init() {
 	bus.AddHandler("sql", GetAlertById)
 	bus.AddHandler("sql", DeleteAlertById)
 	bus.AddHandler("sql", GetAllAlertQueryHandler)
-	//bus.AddHandler("sql", HeartBeat)
+	bus.AddHandler("sql", SetAlertState)
 }
 
-/*
-func HeartBeat(query *m.HeartBeatCommand) error {
-	return inTransaction(func(sess *xorm.Session) error {
-		now := time.Now().Sub(0, 0, 0, 5)
-		activeTime := time.Now().Sub(0, 0, 0, 5)
-		ownHeartbeats := make([]m.HeartBeat, 0)
-		err := x.Where("server_id = ?", query.ServerId).Find(&ownHeartbeats)
-
-		if err != nil {
-			return err
-		}
-
-		if (len(ownHeartbeats)) > 0 && ownHeartbeats[0].Updated > activeTime {
-			//update
-			x.Insert(&m.HeartBeat{ServerId: query.ServerId, Created: now, Updated: now})
-		} else {
-			thisServer := ownHeartbeats[0]
-			thisServer.Updated = now
-			x.Id(thisServer.Id).Update(&thisServer)
-		}
-
-		activeServers := make([]m.HeartBeat, 0)
-		err = x.Where("server_id = ? and updated > ", query.ServerId, now.String()).OrderBy("id").Find(&activeServers)
-
-		if err != nil {
-			return err
-		}
-
-		for i, pos := range activeServers {
-			if pos.ServerId == query.ServerId {
-				query.Result = &m.AlertingClusterInfo{
-					ClusterSize:    len(activeServers),
-					UptimePosition: i,
-				}
-				return nil
-			}
-		}
-
-		return nil
-	})
-}
-*/
-
 func GetAlertById(query *m.GetAlertByIdQuery) error {
 	alert := m.Alert{}
 	has, err := x.Id(query.Id).Get(&alert)
@@ -203,7 +160,7 @@ func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *xor
 		} else {
 			alert.Updated = time.Now()
 			alert.Created = time.Now()
-			alert.State = "UNKNOWN"
+			alert.State = m.AlertStatePending
 			alert.CreatedBy = cmd.UserId
 			alert.UpdatedBy = cmd.UserId
 
@@ -253,3 +210,20 @@ func GetAlertsByDashboardId2(dashboardId int64, sess *xorm.Session) ([]*m.Alert,
 
 	return alerts, nil
 }
+
+func SetAlertState(cmd *m.SetAlertStateCommand) error {
+	return inTransaction(func(sess *xorm.Session) error {
+		alert := m.Alert{}
+
+		if has, err := sess.Id(cmd.AlertId).Get(&alert); err != nil {
+			return err
+		} else if !has {
+			return fmt.Errorf("Could not find alert")
+		}
+
+		alert.State = cmd.State
+		sess.Id(alert.Id).Update(&alert)
+
+		return nil
+	})
+}

+ 38 - 78
pkg/services/sqlstore/alert_notification.go

@@ -3,7 +3,7 @@ package sqlstore
 import (
 	"bytes"
 	"fmt"
-	"strconv"
+	"strings"
 	"time"
 
 	"github.com/go-xorm/xorm"
@@ -31,11 +31,11 @@ func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error {
 	})
 }
 
-func AlertNotificationQuery(query *m.GetAlertNotificationQuery) error {
+func AlertNotificationQuery(query *m.GetAlertNotificationsQuery) error {
 	return getAlertNotifications(query, x.NewSession())
 }
 
-func getAlertNotifications(query *m.GetAlertNotificationQuery, sess *xorm.Session) error {
+func getAlertNotifications(query *m.GetAlertNotificationsQuery, sess *xorm.Session) error {
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)
 
@@ -43,16 +43,15 @@ func getAlertNotifications(query *m.GetAlertNotificationQuery, sess *xorm.Sessio
 	   					  alert_notification.id,
 	   					  alert_notification.org_id,
 	   					  alert_notification.name,
-	                      alert_notification.type,
+	              alert_notification.type,
 	   					  alert_notification.created,
-	                      alert_notification.updated,
-	                      alert_notification.settings,
-						  alert_notification.always_execute
+	              alert_notification.updated,
+	              alert_notification.settings
 	   					  FROM alert_notification
 	   					  `)
 
 	sql.WriteString(` WHERE alert_notification.org_id = ?`)
-	params = append(params, query.OrgID)
+	params = append(params, query.OrgId)
 
 	if query.Name != "" {
 		sql.WriteString(` AND alert_notification.name = ?`)
@@ -61,60 +60,28 @@ func getAlertNotifications(query *m.GetAlertNotificationQuery, sess *xorm.Sessio
 
 	if query.Id != 0 {
 		sql.WriteString(` AND alert_notification.id = ?`)
-		params = append(params, strconv.Itoa(int(query.Id)))
+		params = append(params, query.Id)
 	}
 
 	if len(query.Ids) > 0 {
-		sql.WriteString(` AND (`)
-
-		for i, id := range query.Ids {
-			if i != 0 {
-				sql.WriteString(` OR`)
-			}
-			sql.WriteString(` alert_notification.id = ?`)
-			params = append(params, id)
+		sql.WriteString(` AND alert_notification.id IN (?` + strings.Repeat(",?", len(query.Ids)-1) + ")")
+		for _, v := range query.Ids {
+			params = append(params, v)
 		}
-
-		sql.WriteString(`)`)
 	}
 
-	var searches []*m.AlertNotification
-	if err := sess.Sql(sql.String(), params...).Find(&searches); err != nil {
+	results := make([]*m.AlertNotification, 0)
+	if err := sess.Sql(sql.String(), params...).Find(&results); err != nil {
 		return err
 	}
 
-	var result []*m.AlertNotification
-	var def []*m.AlertNotification
-	if query.IncludeAlwaysExecute {
-
-		if err := sess.Where("org_id = ? AND always_execute = 1", query.OrgID).Find(&def); err != nil {
-			return err
-		}
-
-		result = append(result, def...)
-	}
-
-	for _, s := range searches {
-		canAppend := true
-		for _, d := range result {
-			if d.Id == s.Id {
-				canAppend = false
-				break
-			}
-		}
-
-		if canAppend {
-			result = append(result, s)
-		}
-	}
-
-	query.Result = result
+	query.Result = results
 	return nil
 }
 
 func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error {
 	return inTransaction(func(sess *xorm.Session) error {
-		existingQuery := &m.GetAlertNotificationQuery{OrgID: cmd.OrgID, Name: cmd.Name, IncludeAlwaysExecute: false}
+		existingQuery := &m.GetAlertNotificationsQuery{OrgId: cmd.OrgId, Name: cmd.Name}
 		err := getAlertNotifications(existingQuery, sess)
 
 		if err != nil {
@@ -126,18 +93,15 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
 		}
 
 		alertNotification := &m.AlertNotification{
-			OrgId:         cmd.OrgID,
-			Name:          cmd.Name,
-			Type:          cmd.Type,
-			Created:       time.Now(),
-			Settings:      cmd.Settings,
-			Updated:       time.Now(),
-			AlwaysExecute: cmd.AlwaysExecute,
+			OrgId:    cmd.OrgId,
+			Name:     cmd.Name,
+			Type:     cmd.Type,
+			Settings: cmd.Settings,
+			Created:  time.Now(),
+			Updated:  time.Now(),
 		}
 
-		_, err = sess.Insert(alertNotification)
-
-		if err != nil {
+		if _, err = sess.Insert(alertNotification); err != nil {
 			return err
 		}
 
@@ -148,38 +112,34 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
 
 func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
 	return inTransaction(func(sess *xorm.Session) (err error) {
-		current := &m.AlertNotification{}
-		_, err = sess.Id(cmd.Id).Get(current)
+		current := m.AlertNotification{}
 
-		if err != nil {
+		if _, err = sess.Id(cmd.Id).Get(&current); err != nil {
 			return err
 		}
 
-		alertNotification := &m.AlertNotification{
-			Id:            cmd.Id,
-			OrgId:         cmd.OrgID,
-			Name:          cmd.Name,
-			Type:          cmd.Type,
-			Settings:      cmd.Settings,
-			Updated:       time.Now(),
-			Created:       current.Created,
-			AlwaysExecute: cmd.AlwaysExecute,
+		// check if name exists
+		sameNameQuery := &m.GetAlertNotificationsQuery{OrgId: cmd.OrgId, Name: cmd.Name}
+		if err := getAlertNotifications(sameNameQuery, sess); err != nil {
+			return err
 		}
 
-		sess.UseBool("always_execute")
+		if len(sameNameQuery.Result) > 0 && sameNameQuery.Result[0].Id != current.Id {
+			return fmt.Errorf("Alert notification name %s already exists", cmd.Name)
+		}
 
-		var affected int64
-		affected, err = sess.Id(alertNotification.Id).Update(alertNotification)
+		current.Updated = time.Now()
+		current.Settings = cmd.Settings
+		current.Name = cmd.Name
+		current.Type = cmd.Type
 
-		if err != nil {
+		if affected, err := sess.Id(cmd.Id).Update(current); err != nil {
 			return err
-		}
-
-		if affected == 0 {
+		} else if affected == 0 {
 			return fmt.Errorf("Could not find alert notification")
 		}
 
-		cmd.Result = alertNotification
+		cmd.Result = &current
 		return nil
 	})
 }

+ 75 - 76
pkg/services/sqlstore/alert_state.go

@@ -1,78 +1,77 @@
 package sqlstore
 
-import (
-	"fmt"
-	"time"
-
-	"github.com/go-xorm/xorm"
-	"github.com/grafana/grafana/pkg/bus"
-	m "github.com/grafana/grafana/pkg/models"
-)
-
-func init() {
-	bus.AddHandler("sql", SetNewAlertState)
-	bus.AddHandler("sql", GetAlertStateLogByAlertId)
-	bus.AddHandler("sql", GetLastAlertStateQuery)
-}
-
-func GetLastAlertStateQuery(cmd *m.GetLastAlertStateQuery) error {
-	states := make([]m.AlertState, 0)
-
-	if err := x.Where("alert_id = ? and org_id = ? ", cmd.AlertId, cmd.OrgId).Desc("created").Find(&states); err != nil {
-		return err
-	}
-
-	if len(states) == 0 {
-		cmd.Result = nil
-		return nil
-	}
-
-	cmd.Result = &states[0]
-	return nil
-}
-
-func SetNewAlertState(cmd *m.UpdateAlertStateCommand) error {
-	return inTransaction(func(sess *xorm.Session) error {
-		if !cmd.IsValidState() {
-			return fmt.Errorf("new state is invalid")
-		}
-
-		alert := m.Alert{}
-		has, err := sess.Id(cmd.AlertId).Get(&alert)
-		if err != nil {
-			return err
-		}
-
-		if !has {
-			return fmt.Errorf("Could not find alert")
-		}
-
-		alert.State = cmd.State
-		sess.Id(alert.Id).Update(&alert)
-
-		alertState := m.AlertState{
-			AlertId:         cmd.AlertId,
-			OrgId:           cmd.OrgId,
-			State:           cmd.State,
-			Info:            cmd.Info,
-			Created:         time.Now(),
-			TriggeredAlerts: cmd.TriggeredAlerts,
-		}
-
-		sess.Insert(&alertState)
-
-		cmd.Result = &alert
-		return nil
-	})
-}
-
-func GetAlertStateLogByAlertId(cmd *m.GetAlertsStateQuery) error {
-	states := make([]m.AlertState, 0)
-
-	if err := x.Where("alert_id = ?", cmd.AlertId).Desc("created").Find(&states); err != nil {
-		return err
-	}
-
-	cmd.Result = &states
-	return nil
-}
+// import (
+// 	"fmt"
+// 	"time"
+//
+// 	"github.com/go-xorm/xorm"
+// 	"github.com/grafana/grafana/pkg/bus"
+// 	m "github.com/grafana/grafana/pkg/models"
+// )
+//
+// func init() {
+// 	bus.AddHandler("sql", SetNewAlertState)
+// 	bus.AddHandler("sql", GetAlertStateLogByAlertId)
+// 	bus.AddHandler("sql", GetLastAlertStateQuery)
+// }
+//
+// func GetLastAlertStateQuery(cmd *m.GetLastAlertStateQuery) error {
+// 	states := make([]m.AlertState, 0)
+//
+// 	if err := x.Where("alert_id = ? and org_id = ? ", cmd.AlertId, cmd.OrgId).Desc("created").Find(&states); err != nil {
+// 		return err
+// 	}
+//
+// 	if len(states) == 0 {
+// 		cmd.Result = nil
+// 		return nil
+// 	}
+//
+// 	cmd.Result = &states[0]
+// 	return nil
+// }
+//
+// func SetNewAlertState(cmd *m.UpdateAlertStateCommand) error {
+// 	return inTransaction(func(sess *xorm.Session) error {
+// 		if !cmd.IsValidState() {
+// 			return fmt.Errorf("new state is invalid")
+// 		}
+//
+// 		alert := m.Alert{}
+// 		has, err := sess.Id(cmd.AlertId).Get(&alert)
+// 		if err != nil {
+// 			return err
+// 		}
+//
+// 		if !has {
+// 			return fmt.Errorf("Could not find alert")
+// 		}
+//
+// 		alert.State = cmd.State
+// 		sess.Id(alert.Id).Update(&alert)
+//
+// 		alertState := m.AlertState{
+// 			AlertId: cmd.AlertId,
+// 			OrgId:   cmd.OrgId,
+// 			State:   cmd.State,
+// 			Info:    cmd.Info,
+// 			Created: time.Now(),
+// 		}
+//
+// 		sess.Insert(&alertState)
+//
+// 		cmd.Result = &alert
+// 		return nil
+// 	})
+// }
+//
+// func GetAlertStateLogByAlertId(cmd *m.GetAlertsStateQuery) error {
+// 	states := make([]m.AlertState, 0)
+//
+// 	if err := x.Where("alert_id = ?", cmd.AlertId).Desc("created").Find(&states); err != nil {
+// 		return err
+// 	}
+//
+// 	cmd.Result = &states
+// 	return nil
+// }

+ 98 - 98
pkg/services/sqlstore/alert_state_test.go

@@ -1,100 +1,100 @@
 package sqlstore
 
-import (
-	"testing"
-
-	m "github.com/grafana/grafana/pkg/models"
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-func TestAlertingStateAccess(t *testing.T) {
-	Convey("Test alerting state changes", t, func() {
-		InitTestDB(t)
-
-		testDash := insertTestDashboard("dashboard with alerts", 1, "alert")
-
-		items := []*m.Alert{
-			{
-				PanelId:     1,
-				DashboardId: testDash.Id,
-				OrgId:       testDash.OrgId,
-				Name:        "Alerting title",
-				Description: "Alerting description",
-			},
-		}
-
-		cmd := m.SaveAlertsCommand{
-			Alerts:      items,
-			DashboardId: testDash.Id,
-			OrgId:       1,
-			UserId:      1,
-		}
-
-		err := SaveAlerts(&cmd)
-		So(err, ShouldBeNil)
-
-		Convey("Cannot insert invalid states", func() {
-			err = SetNewAlertState(&m.UpdateAlertStateCommand{
-				AlertId:  1,
-				NewState: "maybe ok",
-				Info:     "Shit just hit the fan",
-			})
-
-			So(err, ShouldNotBeNil)
-		})
-
-		Convey("Changes state to alert", func() {
-
-			err = SetNewAlertState(&m.UpdateAlertStateCommand{
-				AlertId:  1,
-				NewState: "CRITICAL",
-				Info:     "Shit just hit the fan",
-			})
-
-			Convey("can get new state for alert", func() {
-				query := &m.GetAlertByIdQuery{Id: 1}
-				err := GetAlertById(query)
-				So(err, ShouldBeNil)
-				So(query.Result.State, ShouldEqual, "CRITICAL")
-			})
-
-			Convey("Changes state to ok", func() {
-				err = SetNewAlertState(&m.UpdateAlertStateCommand{
-					AlertId:  1,
-					NewState: "OK",
-					Info:     "Shit just hit the fan",
-				})
-
-				Convey("get ok state for alert", func() {
-					query := &m.GetAlertByIdQuery{Id: 1}
-					err := GetAlertById(query)
-					So(err, ShouldBeNil)
-					So(query.Result.State, ShouldEqual, "OK")
-				})
-
-				Convey("should have two event state logs", func() {
-					query := &m.GetAlertsStateQuery{
-						AlertId: 1,
-						OrgId:   1,
-					}
-
-					err := GetAlertStateLogByAlertId(query)
-					So(err, ShouldBeNil)
-
-					So(len(*query.Result), ShouldEqual, 2)
-				})
-
-				Convey("should not get any alerts with critical state", func() {
-					query := &m.GetAlertsQuery{
-						OrgId: 1,
-						State: []string{"Critical", "Warn"},
-					}
-
-					err := HandleAlertsQuery(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 0)
-				})
-			})
-		})
-	})
-}
+// import (
+// 	"testing"
+//
+// 	m "github.com/grafana/grafana/pkg/models"
+// 	. "github.com/smartystreets/goconvey/convey"
+// )
+//
+// func TestAlertingStateAccess(t *testing.T) {
+// 	Convey("Test alerting state changes", t, func() {
+// 		InitTestDB(t)
+//
+// 		testDash := insertTestDashboard("dashboard with alerts", 1, "alert")
+//
+// 		items := []*m.Alert{
+// 			{
+// 				PanelId:     1,
+// 				DashboardId: testDash.Id,
+// 				OrgId:       testDash.OrgId,
+// 				Name:        "Alerting title",
+// 				Description: "Alerting description",
+// 			},
+// 		}
+//
+// 		cmd := m.SaveAlertsCommand{
+// 			Alerts:      items,
+// 			DashboardId: testDash.Id,
+// 			OrgId:       1,
+// 			UserId:      1,
+// 		}
+//
+// 		err := SaveAlerts(&cmd)
+// 		So(err, ShouldBeNil)
+//
+// 		Convey("Cannot insert invalid states", func() {
+// 			err = SetNewAlertState(&m.UpdateAlertStateCommand{
+// 				AlertId:  1,
+// 				NewState: "maybe ok",
+// 				Info:     "Shit just hit the fan",
+// 			})
+//
+// 			So(err, ShouldNotBeNil)
+// 		})
+//
+// 		Convey("Changes state to alert", func() {
+//
+// 			err = SetNewAlertState(&m.UpdateAlertStateCommand{
+// 				AlertId:  1,
+// 				NewState: "CRITICAL",
+// 				Info:     "Shit just hit the fan",
+// 			})
+//
+// 			Convey("can get new state for alert", func() {
+// 				query := &m.GetAlertByIdQuery{Id: 1}
+// 				err := GetAlertById(query)
+// 				So(err, ShouldBeNil)
+// 				So(query.Result.State, ShouldEqual, "CRITICAL")
+// 			})
+//
+// 			Convey("Changes state to ok", func() {
+// 				err = SetNewAlertState(&m.UpdateAlertStateCommand{
+// 					AlertId:  1,
+// 					NewState: "OK",
+// 					Info:     "Shit just hit the fan",
+// 				})
+//
+// 				Convey("get ok state for alert", func() {
+// 					query := &m.GetAlertByIdQuery{Id: 1}
+// 					err := GetAlertById(query)
+// 					So(err, ShouldBeNil)
+// 					So(query.Result.State, ShouldEqual, "OK")
+// 				})
+//
+// 				Convey("should have two event state logs", func() {
+// 					query := &m.GetAlertsStateQuery{
+// 						AlertId: 1,
+// 						OrgId:   1,
+// 					}
+//
+// 					err := GetAlertStateLogByAlertId(query)
+// 					So(err, ShouldBeNil)
+//
+// 					So(len(*query.Result), ShouldEqual, 2)
+// 				})
+//
+// 				Convey("should not get any alerts with critical state", func() {
+// 					query := &m.GetAlertsQuery{
+// 						OrgId: 1,
+// 						State: []string{"Critical", "Warn"},
+// 					}
+//
+// 					err := HandleAlertsQuery(query)
+// 					So(err, ShouldBeNil)
+// 					So(len(query.Result), ShouldEqual, 0)
+// 				})
+// 			})
+// 		})
+// 	})
+// }

+ 1 - 1
pkg/services/sqlstore/migrations/alert_mig.go

@@ -19,6 +19,7 @@ func addAlertMigrations(mg *Migrator) {
 			{Name: "settings", Type: DB_Text, Nullable: false},
 			{Name: "frequency", Type: DB_BigInt, Nullable: false},
 			{Name: "handler", Type: DB_BigInt, Nullable: false},
+			{Name: "severity", Type: DB_Text, Nullable: false},
 			{Name: "enabled", Type: DB_Bool, Nullable: false},
 			{Name: "created", Type: DB_DateTime, Nullable: false},
 			{Name: "updated", Type: DB_DateTime, Nullable: false},
@@ -64,7 +65,6 @@ func addAlertMigrations(mg *Migrator) {
 			{Name: "org_id", Type: DB_BigInt, Nullable: false},
 			{Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false},
 			{Name: "type", Type: DB_NVarchar, Length: 255, Nullable: false},
-			{Name: "always_execute", Type: DB_Bool, Nullable: false},
 			{Name: "settings", Type: DB_Text, Nullable: false},
 			{Name: "created", Type: DB_DateTime, Nullable: false},
 			{Name: "updated", Type: DB_DateTime, Nullable: false},

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

@@ -31,16 +31,15 @@ func (e *GraphiteExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryC
 	result := &tsdb.BatchResult{}
 
 	params := url.Values{
-		"from":          []string{formatTimeRange(context.TimeRange.From)},
-		"until":         []string{context.TimeRange.To},
+		"from":          []string{"-" + formatTimeRange(context.TimeRange.From)},
+		"until":         []string{formatTimeRange(context.TimeRange.To)},
 		"format":        []string{"json"},
 		"maxDataPoints": []string{"500"},
 	}
 
 	for _, query := range queries {
-		params["target"] = []string{
-			query.Query,
-		}
+		params["target"] = []string{query.Query}
+		glog.Debug("Graphite request", "query", query.Query)
 	}
 
 	client := http.Client{Timeout: time.Duration(10 * time.Second)}
@@ -79,5 +78,8 @@ func (e *GraphiteExecutor) Execute(queries tsdb.QuerySlice, context *tsdb.QueryC
 }
 
 func formatTimeRange(input string) string {
+	if input == "now" {
+		return input
+	}
 	return strings.Replace(strings.Replace(input, "m", "min", -1), "M", "mon", -1)
 }

+ 2 - 2
pkg/tsdb/models.go

@@ -46,8 +46,8 @@ type QueryResult struct {
 }
 
 type TimeSeries struct {
-	Name   string
-	Points [][2]float64
+	Name   string       `json:"name"`
+	Points [][2]float64 `json:"points"`
 }
 
 type TimeSeriesSlice []*TimeSeries

+ 2 - 0
pkg/tsdb/request.go

@@ -1,5 +1,7 @@
 package tsdb
 
+type HandleRequestFunc func(req *Request) (*Response, error)
+
 func HandleRequest(req *Request) (*Response, error) {
 	context := NewQueryContext(req.Queries, req.TimeRange)
 

+ 200 - 0
public/app/core/components/jsontree/jsontree.ts

@@ -0,0 +1,200 @@
+
+/** Created by: Alex Wendland (me@alexwendland.com), 2014-08-06
+ *
+ *  angular-json-tree
+ *
+ *  Directive for creating a tree-view out of a JS Object. Only loads
+ *  sub-nodes on demand in order to improve performance of rendering large
+ *  objects.
+ *
+ *  Attributes:
+ *      - object (Object, 2-way): JS object to build the tree from
+ *      - start-expanded (Boolean, 1-way, ?=true): should the tree default to expanded
+ *
+ *  Usage:
+ *      // In the controller
+ *      scope.someObject = {
+ *          test: 'hello',
+ *          array: [1,1,2,3,5,8]
+ *      };
+ *      // In the html
+ *      <json-tree object="someObject"></json-tree>
+ *
+ *  Dependencies:
+ *      - utils (json-tree.js)
+ *      - ajsRecursiveDirectiveHelper (json-tree.js)
+ *
+ *  Test: json-tree-test.js
+ */
+
+import angular from 'angular';
+import coreModule from 'app/core/core_module';
+
+var utils = {
+    /* See link for possible type values to check against.
+     * http://stackoverflow.com/questions/4622952/json-object-containing-array
+     *
+     * Value               Class      Type
+     * -------------------------------------
+     * "foo"               String     string
+     * new String("foo")   String     object
+     * 1.2                 Number     number
+     * new Number(1.2)     Number     object
+     * true                Boolean    boolean
+     * new Boolean(true)   Boolean    object
+     * new Date()          Date       object
+     * new Error()         Error      object
+     * [1,2,3]             Array      object
+     * new Array(1, 2, 3)  Array      object
+     * new Function("")    Function   function
+     * /abc/g              RegExp     object (function in Nitro/V8)
+     * new RegExp("meow")  RegExp     object (function in Nitro/V8)
+     * {}                  Object     object
+     * new Object()        Object     object
+     */
+    is: function is(obj, clazz) {
+        return Object.prototype.toString.call(obj).slice(8, -1) === clazz;
+    },
+
+    // See above for possible values
+    whatClass: function whatClass(obj) {
+        return Object.prototype.toString.call(obj).slice(8, -1);
+    },
+
+    // Iterate over an objects keyset
+    forKeys: function forKeys(obj, f) {
+        for (var key in obj) {
+            if (obj.hasOwnProperty(key) && typeof obj[key] !== 'function') {
+                if (f(key, obj[key])) {
+                    break;
+                }
+            }
+        }
+    }
+};
+
+coreModule.directive('jsonTree', [function jsonTreeDirective() {
+  return {
+    restrict: 'E',
+    scope: {
+      object: '=',
+      startExpanded: '@',
+      rootName: '@',
+    },
+    template: '<json-node key="rootName" value="object" start-expanded="startExpanded"></json-node>'
+  };
+}]);
+
+coreModule.directive('jsonNode', ['ajsRecursiveDirectiveHelper', function jsonNodeDirective(ajsRecursiveDirectiveHelper) {
+  return {
+    restrict: 'E',
+    scope: {
+      key: '=',
+      value: '=',
+      startExpanded: '@'
+    },
+    compile: function jsonNodeDirectiveCompile(elem) {
+      return ajsRecursiveDirectiveHelper.compile(elem, this);
+    },
+    template: ' <span class="json-tree-key" ng-click="toggleExpanded()">{{key}}</span>' +
+      '       <span class="json-tree-leaf-value" ng-if="!isExpandable">{{value}}</span>' +
+      '       <span class="json-tree-branch-preview" ng-if="isExpandable" ng-show="!isExpanded" ng-click="toggleExpanded()">' +
+      '            {{preview}}</span>' +
+      '       <ul class="json-tree-branch-value" ng-if="isExpandable && shouldRender" ng-show="isExpanded">' +
+      '           <li ng-repeat="(subkey,subval) in value">' +
+      '               <json-node key="subkey" value="subval"></json-node>' +
+      '           </li>' +
+      '       </ul>',
+    pre: function jsonNodeDirectiveLink(scope, elem, attrs) {
+      // Set value's type as Class for CSS styling
+      elem.addClass(utils.whatClass(scope.value).toLowerCase());
+      // If the value is an Array or Object, use expandable view type
+      if (utils.is(scope.value, 'Object') || utils.is(scope.value, 'Array')) {
+        scope.isExpandable = true;
+        // Add expandable class for CSS usage
+        elem.addClass('expandable');
+        // Setup preview text
+        var isArray = utils.is(scope.value, 'Array');
+        scope.preview = isArray ? '[ ' : '{ ';
+        utils.forKeys(scope.value, function jsonNodeDirectiveLinkForKeys(key, value) {
+          if (isArray) {
+            scope.preview += value + ', ';
+          } else {
+            scope.preview += key + ': ' + value + ', ';
+          }
+        });
+        scope.preview = scope.preview.substring(0, scope.preview.length - (scope.preview.length > 2 ? 2 : 0)) + (isArray ? ' ]' : ' }');
+        // If directive initially has isExpanded set, also set shouldRender to true
+        if (scope.startExpanded) {
+          scope.shouldRender = true;
+          elem.addClass('expanded');
+        }
+        // Setup isExpanded state handling
+        scope.isExpanded = scope.startExpanded;
+        scope.toggleExpanded = function jsonNodeDirectiveToggleExpanded() {
+          scope.isExpanded = !scope.isExpanded;
+          if (scope.isExpanded) {
+            elem.addClass('expanded');
+          } else {
+            elem.removeClass('expanded');
+          }
+          // For delaying subnode render until requested
+          scope.shouldRender = true;
+        };
+      } else {
+        scope.isExpandable = false;
+        // Add expandable class for CSS usage
+        elem.addClass('not-expandable');
+      }
+    }
+  };
+}]);
+
+/** Added by: Alex Wendland (me@alexwendland.com), 2014-08-09
+ *  Source: http://stackoverflow.com/questions/14430655/recursion-in-angular-directives
+ *
+ *  Used to allow for recursion within directives
+ */
+coreModule.factory('ajsRecursiveDirectiveHelper', ['$compile', function RecursiveDirectiveHelper($compile) {
+  return {
+    /**
+     * Manually compiles the element, fixing the recursion loop.
+     * @param element
+     * @param [link] A post-link function, or an object with function(s) registered via pre and post properties.
+     * @returns An object containing the linking functions.
+     */
+    compile: function RecursiveDirectiveHelperCompile(element, link) {
+      // Normalize the link parameter
+      if (angular.isFunction(link)) {
+        link = {
+          post: link
+        };
+      }
+
+      // Break the recursion loop by removing the contents
+      var contents = element.contents().remove();
+      var compiledContents;
+      return {
+        pre: (link && link.pre) ? link.pre : null,
+        /**
+         * Compiles and re-adds the contents
+         */
+        post: function RecursiveDirectiveHelperCompilePost(scope, element) {
+          // Compile the contents
+          if (!compiledContents) {
+            compiledContents = $compile(contents);
+          }
+          // Re-add the compiled contents to the element
+          compiledContents(scope, function (clone) {
+            element.append(clone);
+          });
+
+          // Call the post-linking function, if any
+          if (link && link.post) {
+            link.post.apply(null, arguments);
+          }
+        }
+      };
+    }
+  };
+}]);

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

@@ -19,6 +19,7 @@ import "./directives/rebuild_on_change";
 import "./directives/give_focus";
 import './jquery_extended';
 import './partials';
+import './components/jsontree/jsontree';
 
 import {grafanaAppDirective} from './components/grafana_app';
 import {sideMenuDirective} from './components/sidemenu/sidemenu';

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

@@ -212,7 +212,7 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
     controllerAs: 'ctrl',
     resolve: loadAlertingBundle,
   })
-  .when('/alerting/notification/:notificationId/edit', {
+  .when('/alerting/notification/:id/edit', {
     templateUrl: 'public/app/features/alerting/partials/notification_edit.html',
     controller: 'AlertNotificationEditCtrl',
     controllerAs: 'ctrl',

+ 1 - 1
public/app/core/services/alert_srv.ts

@@ -16,7 +16,7 @@ export class AlertSrv {
 
   init() {
     this.$rootScope.onAppEvent('alert-error', (e, alert) => {
-      this.set(alert[0], alert[1], 'error', 0);
+      this.set(alert[0], alert[1], 'error', 7000);
     }, this.$rootScope);
 
     this.$rootScope.onAppEvent('alert-warning', (e, alert) => {

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

@@ -1,16 +1,15 @@
 ///<reference path="../../headers/common.d.ts" />
 
-var alertStateToCssMap = {
-  "OK": "icon-gf-online alert-icon-online",
-  "WARN": "icon-gf-warn alert-icon-warn",
-  "CRITICAL": "icon-gf-critical alert-icon-critical",
-  "ACKNOWLEDGED": "icon-gf-alert-disabled"
+var alertSeverityIconMap = {
+  "ok": "icon-gf-online alert-icon-online",
+  "warning": "icon-gf-warn alert-icon-warn",
+  "critical": "icon-gf-critical alert-icon-critical",
 };
 
-function getCssForState(alertState) {
-  return alertStateToCssMap[alertState];
+function getSeverityIconClass(alertState) {
+  return alertSeverityIconMap[alertState];
 }
 
 export default {
-  getCssForState
+  getSeverityIconClass,
 };

+ 1 - 1
public/app/features/alerting/alert_log_ctrl.ts

@@ -22,7 +22,7 @@ export class AlertLogCtrl {
   loadAlertLogs(alertId: number) {
     this.backendSrv.get(`/api/alerts/${alertId}/states`).then(result => {
       this.alertLogs = _.map(result, log => {
-        log.iconCss = alertDef.getCssForState(log.newState);
+        log.iconCss = alertDef.getSeverityIconClass(log.severity);
         log.humanTime = moment(log.created).format("YYYY-MM-DD HH:mm:ss");
         return log;
       });

+ 3 - 6
public/app/features/alerting/alerts_ctrl.ts

@@ -27,11 +27,9 @@ export class AlertListCtrl {
 
   updateFilter() {
     var stats = [];
-
-    this.filter.ok && stats.push('Ok');
+    this.filter.ok && stats.push('OK');
     this.filter.warn && stats.push('Warn');
     this.filter.critical && stats.push('critical');
-    this.filter.acknowleged && stats.push('acknowleged');
 
     this.$route.current.params.state = stats;
     this.$route.updateParams();
@@ -40,10 +38,9 @@ export class AlertListCtrl {
   loadAlerts() {
     var stats = [];
 
-    this.filter.ok && stats.push('Ok');
+    this.filter.ok && stats.push('OK');
     this.filter.warn && stats.push('Warn');
     this.filter.critical && stats.push('critical');
-    this.filter.acknowleged && stats.push('acknowleged');
 
     var params = {
       state: stats
@@ -51,7 +48,7 @@ export class AlertListCtrl {
 
     this.backendSrv.get('/api/alerts', params).then(result => {
       this.alerts = _.map(result, alert => {
-        alert.iconCss = alertDef.getCssForState(alert.state);
+        alert.severityClass = alertDef.getSeverityIconClass(alert.severity);
         return alert;
       });
     });

+ 18 - 31
public/app/features/alerting/notification_edit_ctrl.ts

@@ -6,52 +6,39 @@ import coreModule from '../../core/core_module';
 import config from 'app/core/config';
 
 export class AlertNotificationEditCtrl {
-
-  notification: any;
+  model: any;
 
   /** @ngInject */
-  constructor(private $routeParams, private backendSrv, private $scope) {
-    if ($routeParams.notificationId) {
-      this.loadNotification($routeParams.notificationId);
+  constructor(private $routeParams, private backendSrv, private $scope, private $location) {
+    if ($routeParams.id) {
+      this.loadNotification($routeParams.id);
     } else {
-      this.notification = {
-        settings: {
-          sendCrit: true,
-          sendWarn: true,
-        }
+      this.model = {
+        type: 'email',
+        settings: {}
       };
     }
   }
 
-  loadNotification(notificationId) {
-    this.backendSrv.get(`/api/alert-notifications/${notificationId}`).then(result => {
-      console.log(result);
-      this.notification = result;
+  loadNotification(id) {
+    this.backendSrv.get(`/api/alert-notifications/${id}`).then(result => {
+      this.model = result;
     });
   }
 
   isNew() {
-    return this.notification === undefined || this.notification.id === undefined;
+    return this.model.id === undefined;
   }
 
   save() {
-    if (this.notification.id) {
-      console.log('this.notification: ', this.notification);
-      this.backendSrv.put(`/api/alert-notifications/${this.notification.id}`, this.notification)
-        .then(result => {
-          this.notification = result;
-          this.$scope.appEvent('alert-success', ['Notification created!', '']);
-        }, () => {
-          this.$scope.appEvent('alert-error', ['Unable to create notification.', '']);
-        });
+    if (this.model.id) {
+      this.backendSrv.put(`/api/alert-notifications/${this.model.id}`, this.model).then(res => {
+        this.model = res;
+      });
     } else {
-      this.backendSrv.post(`/api/alert-notifications`, this.notification)
-        .then(result => {
-          this.notification = result;
-          this.$scope.appEvent('alert-success', ['Notification updated!', '']);
-        }, () => {
-          this.$scope.appEvent('alert-error', ['Unable to update notification.', '']);
-        });
+      this.backendSrv.post(`/api/alert-notifications`, this.model).then(res => {
+        this.$location.path('alerting/notification/' + res.id + '/edit');
+      });
     }
   }
 }

+ 5 - 9
public/app/features/alerting/notifications_list_ctrl.ts

@@ -20,16 +20,12 @@ export class AlertNotificationsListCtrl {
     });
   }
 
-  deleteNotification(notificationId) {
-    this.backendSrv.delete(`/api/alerts-notification/${notificationId}`)
-      .then(() => {
-        this.notifications = this.notifications.filter(notification => {
-          return notification.id !== notificationId;
-        });
-        this.$scope.appEvent('alert-success', ['Notification deleted', '']);
-      }, () => {
-        this.$scope.appEvent('alert-error', ['Unable to delete notification', '']);
+  deleteNotification(id) {
+    this.backendSrv.delete(`/api/alert-notifications/${id}`).then(() => {
+      this.notifications = this.notifications.filter(notification => {
+        return notification.id !== notificationId;
       });
+    });
   }
 }
 

+ 8 - 7
public/app/features/alerting/partials/alert_list.html

@@ -1,4 +1,4 @@
-<navbar icon="fa fa-fw fa-list" title="Alerting" title-url="alerting">
+<navbar icon="icon-gf icon-gf-monitoring" title="Alerting" title-url="alerting">
 </navbar>
 
 <div class="page-container" >
@@ -7,28 +7,29 @@
 	</div>
 
   <div class="gf-form-inline">
-		<gf-form-switch class="gf-form" label="Ok" label-class="width-5" checked="ctrl.filter.ok" on-change="ctrl.updateFilter()"></gf-form-switch>
+		<gf-form-switch class="gf-form" label="OK" label-class="width-5" checked="ctrl.filter.ok" on-change="ctrl.updateFilter()"></gf-form-switch>
 		<gf-form-switch class="gf-form" label="Warn" label-class="width-5" checked="ctrl.filter.warn" on-change="ctrl.updateFilter()"></gf-form-switch>
 		<gf-form-switch class="gf-form" label="Critical" label-class="width-5" checked="ctrl.filter.critical" on-change="ctrl.updateFilter()"></gf-form-switch>
-		<gf-form-switch class="gf-form" label="Acknowleged" label-class="width-7" checked="ctrl.filter.acknowleged" on-change="ctrl.updateFilter()"></gf-form-switch>
 	</div>
 
 	<table class="grafana-options-table">
 		<thead>
 			<th style="min-width: 200px"><strong>Name</strong></th>
 			<th style="width: 1%">State</th>
+			<th style="width: 1%">Severity</th>
 			<th style="width: 1%"></th>
 		</thead>
 		<tr ng-repeat="alert in ctrl.alerts">
 			<td>
-				<a href="alerting/{{alert.id}}/states">
+				<a href="dashboard/{{alert.dashboardUri}}?panelId={{alert.panelId}}&fullscreen&edit&editorTab=Alerting">
 					{{alert.name}}
 				</a>
 			</td>
 			<td class="text-center">
-				<a href="alerting/{{alert.id}}/states">
-					<i class="icon-gf {{alert.iconCss}}"></i>
-				</a>
+				{{alert.state}}
+			</td>
+			<td class="text-center">
+				{{alert.severity}}
 			</td>
 			<td class="text-center">
 				<a href="dashboard/{{alert.dashboardUri}}?panelId={{alert.panelId}}&fullscreen&edit&editorTab=Alerting" class="btn btn-inverse btn-small">

+ 0 - 49
public/app/features/alerting/partials/alert_log.html

@@ -6,55 +6,6 @@
     <h1>Alert history for {{ctrl.alert.title}}</h1>
   </div>
 
-  <div class="gf-form-group section" >
-    <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-label max-width-10">
-        {{ctrl.alert.warnOperator}}
-      </div>
-      <div class="gf-form-label max-width-10">
-        {{ctrl.alert.warnLevel}}
-      </div>
-    </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-label max-width-10">
-        {{ctrl.alert.critOperator}}
-      </div>
-      <div class="gf-form-label max-width-10">
-        {{ctrl.alert.critLevel}}
-      </div>
-    </div>
-  </div>
-
-  <div class="gf-form-group section" >
-    <h5 class="section-heading">Aggregators</h5>
-    <div class="gf-form">
-      <span class="gf-form-label width-12">
-        Aggregator
-      </span>
-      <div class="gf-form-label max-width-10">
-        {{ctrl.alert.aggregator}}
-      </div>
-    </div>
-    <div class="gf-form">
-      <span class="gf-form-label width-12">Query range  (seconds)</span>
-      <span class="gf-form-label width-10">{{ctrl.alert.queryRange}}</span>
-    </div>
-
-    <div class="gf-form">
-      <span class="gf-form-label width-12">Frequency (seconds)</span>
-      <span class="gf-form-label width-10">{{ctrl.alert.frequency}}</span>
-    </div>
-  </div>
-
   <table class="filter-table">
     <thead>
       <th style="width: 68px">Status</th>

+ 19 - 21
public/app/features/alerting/partials/notification_edit.html

@@ -1,4 +1,8 @@
-<navbar icon="fa fa-fw fa-list" title="Alerting" title-url="alerting">
+<navbar icon="icon-gf icon-gf-monitoring" title="Alerting" title-url="alerting">
+	<a href="alerting/notifications" class="navbar-page-btn">
+		<i class="fa fa-fw fa-envelope-o"></i>
+		Notifications
+	</a>
 </navbar>
 
 <div class="page-container" >
@@ -6,51 +10,45 @@
 		<h1>Alert notification</h1>
   </div>
 
-	<div class="gf-form-group section">
+	<div class="gf-form-group">
 		<div class="gf-form">
 			<span class="gf-form-label width-8">Name</span>
-			<input type="text" class="gf-form-input max-width-12" ng-model="ctrl.notification.name"></input>
+			<input type="text" class="gf-form-input max-width-15" ng-model="ctrl.model.name" required></input>
 		</div>
 		<div class="gf-form">
 			<span class="gf-form-label width-8">Type</span>
-			<div class="gf-form-select-wrapper width-12">
+			<div class="gf-form-select-wrapper width-15">
 				<select class="gf-form-input"
-					ng-model="ctrl.notification.type"
+					ng-model="ctrl.model.type"
 					ng-options="t for t in ['webhook', 'email']"
 					ng-change="ctrl.typeChanged(notification, $index)">
 				</select>
 			</div>
 		</div>
-		<div class="gf-form">
-			<gf-form-switch class="gf-form" label-class="width-8" label="Always execute" checked="ctrl.notification.alwaysExecute" on-change=""></gf-form-switch>
-		</div>
-		<div class="gf-form">
-			<gf-form-switch class="gf-form" label-class="width-8" label="Send Warning" checked="ctrl.notification.settings.sendWarn" on-change=""></gf-form-switch>
-		</div>
-		<div class="gf-form">
-			<gf-form-switch class="gf-form" label-class="width-8" label="Send Critical" checked="ctrl.notification.settings.sendCrit" on-change=""></gf-form-switch>
-		</div>
 	</div>
-	<div class="gf-form-group section" ng-show="ctrl.notification.type === 'webhook'">
+
+	<div class="gf-form-group" ng-show="ctrl.model.type === 'webhook'">
+    <h3 class="page-heading">Webhook settings</h3>
 		<div class="gf-form">
 			<span class="gf-form-label width-6">Url</span>
-			<input type="text" class="gf-form-input max-width-26" ng-model="ctrl.notification.settings.url"></input>
+			<input type="text" class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url"></input>
 		</div>
 		<div class="gf-form-inline">
 			<div class="gf-form">
 				<span class="gf-form-label width-6">Username</span>
-				<input type="text" class="gf-form-input max-width-10" ng-model="ctrl.notification.settings.username"></input>
+				<input type="text" class="gf-form-input max-width-10" ng-model="ctrl.model.settings.username"></input>
 			</div>
 			<div class="gf-form">
 				<span class="gf-form-label width-6">Password</span>
-				<input type="text" class="gf-form-input max-width-10" ng-model="ctrl.notification.settings.password"></input>
+				<input type="text" class="gf-form-input max-width-10" ng-model="ctrl.model.settings.password"></input>
 			</div>
 		</div>
 	</div>
-	<div class="gf-form-group section" ng-show="ctrl.notification.type === 'email'">
+
+	<div class="gf-form-group section" ng-show="ctrl.model.type === 'email'">
+    <h3 class="page-heading">Email addresses</h3>
 		<div class="gf-form">
-			<span class="gf-form-label width-8">To</span>
-			<input type="text" class="gf-form-input max-width-26" ng-model="ctrl.notification.settings.to">
+      <textarea rows="7" class="gf-form-input width-25" ng-model="ctrl.model.settings.addresses"></textarea>
 		</div>
 	</div>
 

+ 5 - 1
public/app/features/alerting/partials/notifications_list.html

@@ -1,4 +1,8 @@
-<navbar icon="fa fa-fw fa-list" title="Alerting" title-url="alerting">
+<navbar icon="icon-gf icon-gf-monitoring" title="Alerting" title-url="alerting">
+	<a href="alerting/notifications" class="navbar-page-btn">
+		<i class="fa fa-fw fa-envelope-o"></i>
+		Notifications
+	</a>
 </navbar>
 
 <div class="page-container" >

+ 5 - 0
public/app/features/dashboard/viewStateSrv.js

@@ -115,6 +115,11 @@ function (angular, _, $) {
         }
       }
 
+      // if no edit state cleanup tab parm
+      if (!this.state.edit) {
+        delete this.state.tab;
+      }
+
       $location.search(this.serializeToUrl());
       this.syncState();
     };

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

@@ -95,10 +95,10 @@ export class PanelCtrl {
     this.editModeInitiated = true;
     this.events.emit('init-edit-mode', null);
 
-    var routeParams = this.$injector.get('$routeParams');
-    if (routeParams.editorTab) {
+    var urlTab = (this.$injector.get('$routeParams').tab || '').toLowerCase();
+    if (urlTab) {
       this.editorTabs.forEach((tab, i) => {
-        if (tab.title === routeParams.editorTab) {
+        if (tab.title.toLowerCase() === urlTab) {
           this.editorTabIndex = i;
         }
       });
@@ -109,7 +109,7 @@ export class PanelCtrl {
     this.editorTabIndex = newIndex;
     var route = this.$injector.get('$route');
 
-    route.current.params.editorTab = this.editorTabs[newIndex].title;
+    route.current.params.tab = this.editorTabs[newIndex].title.toLowerCase();
     route.updateParams();
   }
 

+ 160 - 74
public/app/plugins/panel/graph/alert_tab_ctrl.ts

@@ -1,8 +1,6 @@
  ///<reference path="../../../headers/common.d.ts" />
 
 import _ from 'lodash';
-import $ from 'jquery';
-import angular from 'angular';
 
 import {
   QueryPartDef,
@@ -19,72 +17,107 @@ var alertQueryDef = new QueryPartDef({
   defaultParams: ['#A', '5m', 'now', 'avg']
 });
 
+var reducerAvgDef = new QueryPartDef({
+  type: 'avg',
+  params: [],
+  defaultParams: []
+});
+
 export class AlertTabCtrl {
   panel: any;
   panelCtrl: any;
-  metricTargets;
+  testing: boolean;
+  testResult: any;
+
   handlers = [{text: 'Grafana', value: 1}, {text: 'External', value: 0}];
-  transforms = [
-    {
-      text: 'Aggregation',
-      type: 'aggregation',
-    },
-    {
-      text: 'Linear Forecast',
-      type: 'forecast',
-    },
+  conditionTypes = [
+    {text: 'Query', value: 'query'},
+    {text: 'Other alert', value: 'other_alert'},
+    {text: 'Time of day', value: 'time_of_day'},
+    {text: 'Day of week', value: 'day_of_week'},
   ];
-  aggregators = ['avg', 'sum', 'min', 'max', 'last'];
   alert: any;
-  thresholds: any;
-  query: any;
-  queryParams: any;
-  transformDef: any;
-  levelOpList = [
+  conditionModels: any;
+  evalFunctions = [
     {text: '>', value: '>'},
     {text: '<', value: '<'},
-    {text: '=', value: '='},
   ];
+  severityLevels = [
+    {text: 'Critical', value: 'critical'},
+    {text: 'Warning', value: 'warning'},
+  ];
+  addNotificationSegment;
+  notifications;
+  alertNotifications;
 
   /** @ngInject */
-  constructor($scope, private $timeout) {
+  constructor(private $scope, private $timeout, private backendSrv, private dashboardSrv, private uiSegmentSrv) {
     this.panelCtrl = $scope.ctrl;
     this.panel = this.panelCtrl.panel;
-    $scope.ctrl = this;
+    this.$scope.ctrl = this;
+  }
+
+  $onInit() {
+    this.addNotificationSegment = this.uiSegmentSrv.newPlusButton();
 
-    this.metricTargets = this.panel.targets.map(val => val);
     this.initModel();
 
     // set panel alert edit mode
-    $scope.$on("$destroy", () => {
+    this.$scope.$on("$destroy", () => {
       this.panelCtrl.editingAlert = false;
       this.panelCtrl.render();
     });
+
+    // build notification model
+    this.notifications = [];
+    this.alertNotifications = [];
+
+    return this.backendSrv.get('/api/alert-notifications').then(res => {
+      this.notifications = res;
+
+      _.each(this.alert.notifications, item => {
+        var model = _.findWhere(this.notifications, {id: item.id});
+        if (model) {
+          this.alertNotifications.push(model);
+        }
+      });
+    });
   }
 
-  getThresholdWithDefaults(threshold) {
-    threshold = threshold || {};
-    threshold.op = threshold.op || '>';
-    threshold.value = threshold.value || undefined;
-    return threshold;
+  getNotifications() {
+    return Promise.resolve(this.notifications.map(item => {
+      return this.uiSegmentSrv.newSegment(item.name);
+    }));
   }
 
-  initModel() {
-    var alert = this.alert = this.panel.alert = this.panel.alert || {};
+  notificationAdded() {
+    var model = _.findWhere(this.notifications, {name: this.addNotificationSegment.value});
+    if (!model) {
+      return;
+    }
 
-    // set threshold defaults
-    alert.warn = this.getThresholdWithDefaults(alert.warn);
-    alert.crit = this.getThresholdWithDefaults(alert.crit);
+    this.alertNotifications.push({name: model.name});
+    this.alert.notifications.push({id: model.id});
 
-    alert.query = alert.query || {};
-    alert.query.refId = alert.query.refId || 'A';
-    alert.query.from = alert.query.from || '5m';
-    alert.query.to = alert.query.to || 'now';
+    // reset plus button
+    this.addNotificationSegment.value = this.uiSegmentSrv.newPlusButton().value;
+    this.addNotificationSegment.html = this.uiSegmentSrv.newPlusButton().html;
+  }
+
+  removeNotification(index) {
+    this.alert.notifications.splice(index, 1);
+    this.alertNotifications.splice(index, 1);
+  }
+
+  initModel() {
+    var alert = this.alert = this.panel.alert = this.panel.alert || {};
 
-    alert.transform = alert.transform || {};
-    alert.transform.type = alert.transform.type || 'aggregation';
-    alert.transform.method = alert.transform.method || 'avg';
+    alert.conditions = alert.conditions || [];
+    if (alert.conditions.length === 0) {
+      alert.conditions.push(this.buildDefaultCondition());
+    }
 
+    alert.severity = alert.severity || 'critical';
     alert.frequency = alert.frequency || '60s';
     alert.handler = alert.handler || 1;
     alert.notifications = alert.notifications || [];
@@ -93,50 +126,87 @@ export class AlertTabCtrl {
     alert.name = alert.name || defaultName;
     alert.description = alert.description || defaultName;
 
-    // great temp working model
-    this.queryParams = {
-      params: [alert.query.refId, alert.query.from, alert.query.to]
-    };
-
-    // init the query part components model
-    this.query = new QueryPart(this.queryParams, alertQueryDef);
-    this.transformDef = _.findWhere(this.transforms, {type: alert.transform.type});
+    this.conditionModels = _.reduce(alert.conditions, (memo, value) => {
+      memo.push(this.buildConditionModel(value));
+      return memo;
+    }, []);
 
-    this.panelCtrl.editingAlert = true;
+    ///this.panelCtrl.editingAlert = true;
+    this.syncThresholds();
     this.panelCtrl.render();
   }
 
-  queryUpdated() {
-    this.alert.query = {
-      refId: this.query.params[0],
-      from: this.query.params[1],
-      to: this.query.params[2],
+  syncThresholds() {
+    var threshold: any = {};
+    if (this.panel.thresholds && this.panel.thresholds.length > 0) {
+      threshold = this.panel.thresholds[0];
+    } else {
+      this.panel.thresholds = [threshold];
+    }
+
+    var updated = false;
+    for (var condition of this.conditionModels) {
+      if (condition.type === 'query') {
+        var value = condition.evaluator.params[0];
+        if (!_.isNumber(value)) {
+          continue;
+        }
+
+        if (value !== threshold.from) {
+          threshold.from = value;
+          updated = true;
+        }
+
+        if (condition.evaluator.type === '<' && threshold.to !== -Infinity) {
+          threshold.to = -Infinity;
+          updated = true;
+        } else if (condition.evaluator.type === '>' && threshold.to !== Infinity) {
+          threshold.to = Infinity;
+          updated = true;
+        }
+      }
+    }
+
+    return updated;
+  }
+
+  buildDefaultCondition() {
+    return {
+      type: 'query',
+      query: {params: ['A', '5m', 'now']},
+      reducer: {type: 'avg', params: []},
+      evaluator: {type: '>', params: [null]},
     };
   }
 
-  transformChanged() {
-    // clear model
-    this.alert.transform = {type: this.alert.transform.type};
-    this.transformDef = _.findWhere(this.transforms, {type: this.alert.transform.type});
+  buildConditionModel(source) {
+    var cm: any = {source: source, type: source.type};
 
-    switch (this.alert.transform.type) {
-      case 'aggregation':  {
-        this.alert.transform.method = 'avg';
-        break;
-      }
-      case "forecast": {
-        this.alert.transform.timespan = '7d';
-        break;
-      }
-    }
+    cm.queryPart = new QueryPart(source.query, alertQueryDef);
+    cm.reducerPart = new QueryPart({params: []}, reducerAvgDef);
+    cm.evaluator = source.evaluator;
+
+    return cm;
+  }
+
+  queryPartUpdated(conditionModel) {
+  }
+
+  addCondition(type) {
+    var condition = this.buildDefaultCondition();
+    // add to persited model
+    this.alert.conditions.push(condition);
+    // add to view model
+    this.conditionModels.push(this.buildConditionModel(condition));
+  }
+
+  removeCondition(index) {
+    this.alert.conditions.splice(index, 1);
+    this.conditionModels.splice(index, 1);
   }
 
   delete() {
     this.alert.enabled = false;
-    this.alert.warn.value = undefined;
-    this.alert.crit.value = undefined;
-
-    // reset model but keep thresholds instance
     this.initModel();
   }
 
@@ -145,8 +215,24 @@ export class AlertTabCtrl {
     this.initModel();
   }
 
-  thresholdsUpdated() {
-    this.panelCtrl.render();
+  thresholdUpdated() {
+    if (this.syncThresholds()) {
+      this.panelCtrl.render();
+    }
+  }
+
+  test() {
+    this.testing = true;
+
+    var payload = {
+      dashboard: this.dashboardSrv.getCurrent().getSaveModelClone(),
+      panelId: this.panelCtrl.panel.id,
+    };
+
+    return this.backendSrv.post('/api/alerts/test', payload).then(res => {
+      this.testResult = res;
+      this.testing = false;
+    });
   }
 }
 

+ 8 - 54
public/app/plugins/panel/graph/graph.js

@@ -184,7 +184,7 @@ function (angular, $, moment, _, kbn, GraphTooltip, thresholds) {
           // give space to alert editing
           if (ctrl.editingAlert) {
             if (!thresholdControls) {
-              elem.css('margin-right', '220px');
+              elem.css('margin-right', '110px');
               thresholdControls = new ThresholdControls(ctrl);
             }
           } else if (thresholdControls) {
@@ -327,74 +327,28 @@ function (angular, $, moment, _, kbn, GraphTooltip, thresholds) {
         }
 
         function addGridThresholds(options, panel) {
-          if (!panel.alert) {
+          if (!panel.thresholds || panel.thresholds.length === 0) {
             return;
           }
 
-          var crit = panel.alert.crit;
-          var warn = panel.alert.warn;
-          var critEdge = Infinity;
-
-          if (_.isNumber(crit.value)) {
-            if (crit.op === '<') {
-              critEdge = -Infinity;
+          for (var i = 0; i < panel.thresholds.length; i++) {
+            var threshold = panel.thresholds[i];
+            if (!_.isNumber(threshold.from)) {
+              continue;
             }
 
             // fill
             options.grid.markings.push({
-              yaxis: {from: crit.value, to: critEdge},
+              yaxis: {from: threshold.from, to: threshold.to},
               color: 'rgba(234, 112, 112, 0.10)',
             });
 
             // line
             options.grid.markings.push({
-              yaxis: {from: crit.value, to: crit.value},
+              yaxis: {from: threshold.from, to: threshold.from},
               color: '#ed2e18'
             });
           }
-
-          if (_.isNumber(warn.value)) {
-            //var warnEdge = crit.value || Infinity;
-            var warnEdge;
-            if (crit.value) {
-              warnEdge = crit.value;
-            } else {
-              warnEdge = warn.op === '<' ? -Infinity : Infinity;
-            }
-
-            // fill
-            options.grid.markings.push({
-              yaxis: {from: warn.value, to: warnEdge},
-              color: 'rgba(216, 200, 27, 0.10)',
-            });
-
-            // line
-            options.grid.markings.push({
-              yaxis: {from: warn.value, to: warn.value},
-              color: '#F79520'
-            });
-          }
-
-          // if (_.isNumber(panel.grid.threshold1)) {
-          //   var limit1 = panel.grid.thresholdLine ? panel.grid.threshold1 : (panel.grid.threshold2 || null);
-          //   options.grid.markings.push({
-          //     yaxis: { from: panel.grid.threshold1, to: limit1 },
-          //     color: panel.grid.threshold1Color
-          //   });
-          //
-          //   if (_.isNumber(panel.grid.threshold2)) {
-          //     var limit2;
-          //     if (panel.grid.thresholdLine) {
-          //       limit2 = panel.grid.threshold2;
-          //     } else {
-          //       limit2 = panel.grid.threshold1 > panel.grid.threshold2 ?  -Infinity : +Infinity;
-          //     }
-          //     options.grid.markings.push({
-          //       yaxis: { from: panel.grid.threshold2, to: limit2 },
-          //       color: panel.grid.threshold2Color
-          //     });
-          //   }
-          // }
         }
 
         function addAnnotations(options) {

+ 2 - 1
public/app/plugins/panel/graph/module.ts

@@ -105,6 +105,7 @@ class GraphCtrl extends MetricsPanelCtrl {
     // other style overrides
     seriesOverrides: [],
     alerting: {},
+    thresholds: [],
   };
 
   /** @ngInject */
@@ -132,7 +133,7 @@ class GraphCtrl extends MetricsPanelCtrl {
     this.addEditorTab('Display', 'public/app/plugins/panel/graph/tab_display.html', 4);
 
     if (config.alertingEnabled) {
-      this.addEditorTab('Alerting', graphAlertEditor, 5);
+      this.addEditorTab('Alert', graphAlertEditor, 5);
     }
 
     this.logScales = {

+ 126 - 133
public/app/plugins/panel/graph/partials/tab_alerting.html

@@ -1,147 +1,140 @@
 
-<div ng-if="!ctrl.alert.enabled">
-  <div class="gf-form-group">
-    <h5 class="section-heading">Visual Thresholds</h5>
-    <div class="gf-form-inline">
-      <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.crit.op" options="ctrl.operatorList" custom="false" css-class="query-segment-operator" on-change="ctrl.thresholdsUpdated()"></metric-segment-model>
-        <input class="gf-form-input max-width-7" type="number" ng-model="ctrl.alert.crit.value" ng-change="ctrl.thresholdsUpdated()"></input>
-      </div>
-      <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.operatorList" custom="false" css-class="query-segment-operator" on-change="ctrl.thresholdsUpdated()"></metric-segment-model>
-        <input class="gf-form-input max-width-7" type="number" ng-model="ctrl.alert.warn.value" ng-change="ctrl.thresholdsUpdated()"></input>
-      </div>
-    </div>
-  </div>
-</div>
+<!-- <div ng&#45;if="!ctrl.alert.enabled"> -->
+<!--   <div class="gf&#45;form&#45;group"> -->
+<!--     <h5 class="section&#45;heading">Visual Thresholds</h5> -->
+<!--     <div class="gf&#45;form&#45;inline"> -->
+<!--       <div class="gf&#45;form"> -->
+<!--         <span class="gf&#45;form&#45;label"> -->
+<!--           <i class="icon&#45;gf icon&#45;gf&#45;warn alert&#45;icon&#45;critical"></i> -->
+<!--           Critcal if -->
+<!--         </span> -->
+<!--         <metric&#45;segment&#45;model property="ctrl.alert.crit.op" options="ctrl.operatorList" custom="false" css&#45;class="query&#45;segment&#45;operator" on&#45;change="ctrl.thresholdsUpdated()"></metric&#45;segment&#45;model> -->
+<!--         <input class="gf&#45;form&#45;input max&#45;width&#45;7" type="number" ng&#45;model="ctrl.alert.crit.value" ng&#45;change="ctrl.thresholdsUpdated()"></input> -->
+<!--       </div> -->
+<!--       <div class="gf&#45;form"> -->
+<!--         <span class="gf&#45;form&#45;label"> -->
+<!--           <i class="icon&#45;gf icon&#45;gf&#45;warn alert&#45;icon&#45;warn"></i> -->
+<!--           Warn if -->
+<!--         </span> -->
+<!--         <metric&#45;segment&#45;model property="ctrl.alert.warn.op" options="ctrl.operatorList" custom="false" css&#45;class="query&#45;segment&#45;operator" on&#45;change="ctrl.thresholdsUpdated()"></metric&#45;segment&#45;model> -->
+<!--         <input class="gf&#45;form&#45;input max&#45;width&#45;7" type="number" ng&#45;model="ctrl.alert.warn.value" ng&#45;change="ctrl.thresholdsUpdated()"></input> -->
+<!--       </div> -->
+<!--     </div> -->
+<!--   </div> -->
+<!-- </div> -->
 
 <div ng-if="ctrl.alert.enabled">
-  <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>
-    </div>
-
-    <div class="gf-form-group section">
-      <h5 class="section-heading">Thresholds</h5>
-      <div class="gf-form-inline">
-        <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.crit.op" options="ctrl.operatorList" custom="false" css-class="query-segment-operator" on-change="ctrl.thresholdsUpdated()"></metric-segment-model>
-          <input class="gf-form-input max-width-7" type="number" ng-model="ctrl.alert.crit.value" ng-change="ctrl.thresholdsUpdated()"></input>
-        </div>
-        <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.operatorList" custom="false" css-class="query-segment-operator" on-change="ctrl.thresholdsUpdated()"></metric-segment-model>
-          <input class="gf-form-input max-width-7" type="number" ng-model="ctrl.alert.warn.value" ng-change="ctrl.thresholdsUpdated()"></input>
-        </div>
-      </div>
-    </div>
-  </div>
+	<div class="gf-form-group">
+		<h5 class="section-heading">Alert Rule</h5>
+		<div class="gf-form-inline">
+			<div class="gf-form">
+				<span class="gf-form-label">Name</span>
+				<input type="text" class="gf-form-input width-22" ng-model="ctrl.alert.name">
+			</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 class="gf-form">
+				<span class="gf-form-label">Severity</span>
+				<div class="gf-form-select-wrapper">
+					<select class="gf-form-input" ng-model="ctrl.alert.severity" ng-options="f.value as f.text for f in ctrl.severityLevels">
+					</select>
+				</div>
+			</div>
+		</div>
+	</div>
 
-  <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">Handler</span>
-          <div class="gf-form-select-wrapper">
-            <select   class="gf-form-input"
-                      ng-model="ctrl.alert.handler"
-                      ng-options="f.value as f.text for f in ctrl.handlers">
-            </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 class="gf-form-group">
+    <h5 class="section-heading">Conditions</h5>
+    <div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
+      <div class="gf-form">
+				<span class="gf-form-label query-keyword width-5" ng-if="$index">AND</span>
+				<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
       </div>
-    </div>
-    <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>
-          <input class="gf-form-input max-width-7" type="text" ng-model="ctrl.alert.notify"></input>
-          <!--
-          <bootstrap-tagsinput ng-model="ctrl.alert.notify" tagclass="label label-tag" placeholder="add tags">
-          </bootstrap-tagsinput>
-          -->
-        </div>
+      <div class="gf-form">
+        <query-part-editor
+           class="gf-form-label query-part"
+           part="conditionModel.queryPart"
+           part-updated="ctrl.queryPartUpdated(conditionModel)">
+        </query-part-editor>
       </div>
-    </div>
-  </div>
-
-  <div class="gf-form-group section">
-    <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.alert.name">
-    </div>
-    <div class="gf-form-inline">
       <div class="gf-form">
-        <span class="gf-form-label width-10" style="margin-top: -73px;">Alert description</span>
+        <span class="gf-form-label">Reducer</span>
+        <query-part-editor
+              class="gf-form-label query-part"
+              part="conditionModel.reducerPart"
+              part-updated="ctrl.reducerPartUpdated(conditionModel)">
+        </query-part-editor>
       </div>
       <div class="gf-form">
-        <textarea rows="5" ng-model="ctrl.alert.description" class="gf-form-input width-22"></textarea>
+        <span class="gf-form-label">Value</span>
+        <metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-segment-operator" on-change="ctrl.thresholdUpdated()"></metric-segment-model>
+        <input class="gf-form-input max-width-7" type="number" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.thresholdUpdated()"></input>
       </div>
+      <div class="gf-form">
+        <label class="gf-form-label">
+          <a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)">
+            <i class="fa fa-trash"></i>
+          </a>
+        </label>
+			</div>
     </div>
-  </div>
+
+		<div class="gf-form">
+			<label class="gf-form-label dropdown">
+				<a class="pointer dropdown-toggle" data-toggle="dropdown">
+					<i class="fa fa-plus"></i>
+				</a>
+				<ul class="dropdown-menu" role="menu">
+					<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem">
+						<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a>
+					</li>
+				</ul>
+			</label>
+		</div>
+	</div>
+
+	<div class="gf-form-group">
+		<h5 class="section-heading">Notifications</h5>
+		<div class="gf-form-inline">
+			<div class="gf-form max-width-30">
+				<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications">
+					{{nc.name}}
+					<i class="fa fa-remove pointer" ng-click="ctrl.removeNotification($index)"></i>
+				</span>
+				<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment>
+			</div>
+		</div>
+	</div>
+
+	<div class="gf-form-group">
+		<div class="gf-form-button-row">
+			<button class="btn btn-inverse" ng-click="ctrl.test()">
+				Test Rule
+			</button>
+
+			<button class="btn btn-inverse" ng-click="ctrl.delete()">
+				Delete Alert
+			</button>
+		</div>
+	</div>
+
+</div>
+
+<div class="gf-form-group" ng-if="ctrl.testing">
+	Evaluating rule <i class="fa fa-spinner fa-spin"></i>
+</div>
+
+<div class="gf-form-group" ng-if="ctrl.testResult">
+	<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree>
 </div>
 
-<div class="editor-row">
-  <div class="gf-form-button-row">
-    <button class="btn btn-danger" ng-click="ctrl.delete()" ng-show="ctrl.alert.enabled">Delete</button>
-    <button class="btn btn-inverse" ng-click="ctrl.enable()" ng-hide="ctrl.alert.enabled">
-      <i class="icon-gf icon-gf-alert"></i>
-      Create Alert
-    </button>
-  </div>
+<div class="gf-form-group" ng-if="!ctrl.alert.enabled">
+	<div class="gf-form-button-row">
+		<button class="btn btn-inverse" ng-click="ctrl.enable()">
+			<i class="icon-gf icon-gf-alert"></i>
+			Create Alert
+		</button>
+	</div>
 </div>

+ 5 - 4
public/app/plugins/panel/graph/thresholds.ts

@@ -8,10 +8,10 @@ export class ThresholdControls {
   plot: any;
   placeholder: any;
   height: any;
-  alert: any;
+  thresholds: any;
 
   constructor(private panelCtrl) {
-    this.alert = this.panelCtrl.panel.alert;
+    this.thresholds = this.panelCtrl.panel.thresholds;
   }
 
   getHandleInnerHtml(type, op, value) {
@@ -120,8 +120,9 @@ export class ThresholdControls {
     this.placeholder = plot.getPlaceholder();
     this.height = plot.height();
 
-    this.renderHandle('crit', this.alert.crit, 10);
-    this.renderHandle('warn', this.alert.warn, this.height-30);
+    if (this.thresholds.length > 0) {
+      this.renderHandle('crit', this.thresholds[0], 10);
+    }
   }
 }
 

+ 7 - 26
public/emails/alert_notification.html

@@ -113,37 +113,18 @@ color: #FFFFFF !important;
 					<table class="container" style="border-collapse: collapse; border-spacing: 0; margin: 0 auto; padding: 0; text-align: inherit; vertical-align: top; width: 580px">
 						<tr style="padding: 0; text-align: left; vertical-align: top" align="left">
 							<td style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0; text-align: left; vertical-align: top; word-break: break-word" align="left" valign="top">
-								
+								{{Subject .Subject "Grafana Alert: {{.Severity}} {{.RuleName}}"}}
 
-{{Subject .Subject "Grafana Alert: [ {{.State}} ] {{.Name}}" }}
+<br />
+<br />
 
-Alertstate: {{.State}}<br />
-{{.AlertPageUrl}}<br />
-{{.DashboardLink}}<br />
-{{.Description}}<br />
+Alert rule: {{.RuleName}}<br />
+Alert state: {{.RuleState}}<br />
 
-{{if eq .State "Ok"}}
-    Everything is Ok     
-{{end}}
+<a href="{{.RuleLink}}" style="color: #E67612; text-decoration: none">Link to alert rule</a>
 
-{{if ne .State "Ok" }}
-    <img src="{{.DashboardImage}}" style="-ms-interpolation-mode: bicubic; clear: both; display: block; float: left; max-width: 100%; outline: none; text-decoration: none; width: auto" align="left" />
+<br />
 
-    <table class="row" style="border-collapse: collapse; border-spacing: 0; display: block; padding: 0px; position: relative; text-align: left; vertical-align: top; width: 100%">
-        <tr style="padding: 0; text-align: left; vertical-align: top" align="left">
-            <td class="expander" style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0; text-align: left; vertical-align: top; visibility: hidden; width: 0px; word-break: break-word" align="left" valign="top">Serie</td>
-            <td class="expander" style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0; text-align: left; vertical-align: top; visibility: hidden; width: 0px; word-break: break-word" align="left" valign="top">State</td>
-            <td class="expander" style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0; text-align: left; vertical-align: top; visibility: hidden; width: 0px; word-break: break-word" align="left" valign="top">Actual value</td>
-        </tr>
-        {{ range $ta := .TriggeredAlerts}}            
-        <tr style="padding: 0; text-align: left; vertical-align: top" align="left">
-            <td class="expander" style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0; text-align: left; vertical-align: top; visibility: hidden; width: 0px; word-break: break-word" align="left" valign="top">{{$ta.Name}}</td>
-            <td class="expander" style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0; text-align: left; vertical-align: top; visibility: hidden; width: 0px; word-break: break-word" align="left" valign="top">{{$ta.State}}</td>
-            <td class="expander" style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0; text-align: left; vertical-align: top; visibility: hidden; width: 0px; word-break: break-word" align="left" valign="top">{{$ta.ActualValue}}</td>
-        </tr>
-        {{end}}
-    </table>
-{{end}}
 
 								
 								<table class="row footer" style="border-collapse: collapse; border-spacing: 0; display: block; margin-top: 20px; padding: 0px; position: relative; text-align: left; vertical-align: top; width: 100%">

+ 1 - 1
public/emails/invited_to_org.html

@@ -149,7 +149,7 @@ color: #FFFFFF !important;
 					<td class="center" style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0px 0px 10px; text-align: center; vertical-align: top; word-break: break-word" align="center" valign="top">
 						<table class="better-button" align="center" border="0" cellspacing="0" cellpadding="0" style="border-collapse: collapse; border-spacing: 0; margin-bottom: 20px; margin-top: 10px; padding: 0; text-align: left; vertical-align: top">
 							<tr style="padding: 0; text-align: left; vertical-align: top" align="left">
-								<td align="center" class="better-button" bgcolor="#ff8f2b" style="-moz-border-radius: 2px; -moz-hyphens: auto; -webkit-border-radius: 2px; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; border-radius: 2px; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0px; text-align: left; vertical-align: top; word-break: break-word" valign="top"><a href="{{.AppUrl}}" target="_blank" style="-moz-border-radius: 2px; -webkit-border-radius: 2px; border-radius: 2px; border: 1px solid #ff8f2b; color: #FFF; display: inline-block; padding: 12px 25px; text-decoration: none">Log in now</a></td>
+								<td align="center" class="better-button" bgcolor="#ff8f2b" style="-moz-border-radius: 2px; -moz-hyphens: auto; -webkit-border-radius: 2px; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; border-radius: 2px; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0px; text-align: left; vertical-align: top; word-break: break-word" valign="top"><a href="{{.AppUrl}}" target="_blank" style="-moz-border-radius: 2px; -webkit-border-radius: 2px; border: 1px solid #ff8f2b; border-radius: 2px; color: #FFF; display: inline-block; padding: 12px 25px; text-decoration: none">Log in now</a></td>
 							</tr>
 						</table>
 					</td>

+ 1 - 1
public/emails/new_user_invite.html

@@ -147,7 +147,7 @@ color: #FFFFFF !important;
 					<td class="center" style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0px 0px 10px; text-align: center; vertical-align: top; word-break: break-word" align="center" valign="top">
 	                    <table class="better-button" align="center" border="0" cellspacing="0" cellpadding="0" style="border-collapse: collapse; border-spacing: 0; margin-bottom: 20px; margin-top: 10px; padding: 0; text-align: left; vertical-align: top">
 	                    	<tr style="padding: 0; text-align: left; vertical-align: top" align="left">
-	                          <td align="center" class="better-button" bgcolor="#ff8f2b" style="-moz-border-radius: 2px; -moz-hyphens: auto; -webkit-border-radius: 2px; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; border-radius: 2px; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0px; text-align: left; vertical-align: top; word-break: break-word" valign="top"><a href="{{.LinkUrl}}" target="_blank" style="-moz-border-radius: 2px; -webkit-border-radius: 2px; border-radius: 2px; border: 1px solid #ff8f2b; color: #FFF; display: inline-block; padding: 12px 25px; text-decoration: none">Accept Invitation</a></td>
+	                          <td align="center" class="better-button" bgcolor="#ff8f2b" style="-moz-border-radius: 2px; -moz-hyphens: auto; -webkit-border-radius: 2px; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; border-radius: 2px; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0px; text-align: left; vertical-align: top; word-break: break-word" valign="top"><a href="{{.LinkUrl}}" target="_blank" style="-moz-border-radius: 2px; -webkit-border-radius: 2px; border: 1px solid #ff8f2b; border-radius: 2px; color: #FFF; display: inline-block; padding: 12px 25px; text-decoration: none">Accept Invitation</a></td>
 	                        </tr>
 	                    </table>
 					</td>

+ 1 - 1
public/emails/signup_started.html

@@ -148,7 +148,7 @@ color: #FFFFFF !important;
 					<td class="center" style="-moz-hyphens: auto; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0px 0px 10px; text-align: center; vertical-align: top; word-break: break-word" align="center" valign="top">
 						<table class="better-button" align="center" border="0" cellspacing="0" cellpadding="0" style="border-collapse: collapse; border-spacing: 0; margin-bottom: 20px; margin-top: 10px; padding: 0; text-align: left; vertical-align: top">
 							<tr style="padding: 0; text-align: left; vertical-align: top" align="left">
-								<td align="center" class="better-button" bgcolor="#ff8f2b" style="-moz-border-radius: 2px; -moz-hyphens: auto; -webkit-border-radius: 2px; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; border-radius: 2px; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0px; text-align: left; vertical-align: top; word-break: break-word" valign="top"><a href="{{.SignUpUrl}}" target="_blank" style="-moz-border-radius: 2px; -webkit-border-radius: 2px; border-radius: 2px; border: 1px solid #ff8f2b; color: #FFF; display: inline-block; padding: 12px 25px; text-decoration: none">Complete Sign Up</a></td>
+								<td align="center" class="better-button" bgcolor="#ff8f2b" style="-moz-border-radius: 2px; -moz-hyphens: auto; -webkit-border-radius: 2px; -webkit-font-smoothing: antialiased; -webkit-hyphens: auto; -webkit-text-size-adjust: none; border-collapse: collapse !important; border-radius: 2px; color: #222222; font-family: 'Open Sans', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: normal; hyphens: auto; line-height: 19px; margin: 0; padding: 0px; text-align: left; vertical-align: top; word-break: break-word" valign="top"><a href="{{.SignUpUrl}}" target="_blank" style="-moz-border-radius: 2px; -webkit-border-radius: 2px; border: 1px solid #ff8f2b; border-radius: 2px; color: #FFF; display: inline-block; padding: 12px 25px; text-decoration: none">Complete Sign Up</a></td>
 							</tr>
 						</table>
 					</td>

+ 1 - 0
public/sass/_grafana.scss

@@ -71,6 +71,7 @@
 @import "components/query_editor";
 @import "components/tabbed_view";
 @import "components/query_part";
+@import "components/jsontree";
 
 // PAGES
 @import "pages/login";

+ 61 - 0
public/sass/components/_jsontree.scss

@@ -0,0 +1,61 @@
+/* Structure */
+json-tree {
+  .json-tree-key {
+    vertical-align: middle;
+  }
+  .expandable {
+    position: relative;
+    &::before {
+      pointer-events: none;
+    }
+    &::before, & > .json-tree-key {
+      cursor: pointer;
+    }
+  }
+  .json-tree-branch-preview {
+    display: inline-block;
+    vertical-align: middle;
+  }
+}
+
+/* Looks */
+json-tree {
+  ul {
+    padding-left: $spacer;
+  }
+  li, ul {
+    list-style: none;
+  }
+  li {
+    line-height: 1.3rem;
+  }
+  .json-tree-key {
+    color: $variable;
+    padding: 5px 10px 5px 15px;
+    &::after {
+      content: ':';
+    }
+  }
+  json-node.expandable {
+    &::before {
+      content: '\25b6';
+      position: absolute;
+      left: 0px;
+      font-size: 8px;
+      transition: transform .1s ease;
+    }
+    &.expanded::before {
+      transform: rotate(90deg);
+    }
+  }
+  .json-tree-leaf-value, .json-tree-branch-preview {
+    word-break: break-all;
+  }
+  .json-tree-branch-preview {
+    overflow: hidden;
+    font-style: italic;
+    max-width: 40%;
+    height: 1.5em;
+    opacity: .7;
+  }
+}

+ 1 - 1
public/sass/components/_tags.scss

@@ -34,7 +34,7 @@
 }
 
 .label-tag:hover {
-  opacity: 0.85;
+   opacity: 0.85;
   background-color: darken($purple, 10%);
 }