mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'upstream/master' into postgres-query-builder
This commit is contained in:
commit
e590068082
12
CHANGELOG.md
12
CHANGELOG.md
@ -1,11 +1,23 @@
|
||||
# 5.1.0 (unreleased)
|
||||
|
||||
* **Postgres/MySQL**: Ability to insert 0s or nulls for missing intervals [#9487](https://github.com/grafana/grafana/issues/9487), thanks [@svenklemm](https://github.com/svenklemm)
|
||||
* **Graph**: Thresholds for Right Y axis [#7107](https://github.com/grafana/grafana/issues/7107), thx [@ilgizar](https://github.com/ilgizar)
|
||||
* **Alerting**: Pausing/un alerts now updates new_state_date [#10942](https://github.com/grafana/grafana/pull/10942)
|
||||
* **Templating**: Add comma templating format [#10632](https://github.com/grafana/grafana/issues/10632), thx [@mtanda](https://github.com/mtanda)
|
||||
|
||||
### Minor
|
||||
* **OpsGenie**: Add triggered alerts as description [#11046](https://github.com/grafana/grafana/pull/11046), thx [@llamashoes](https://github.com/llamashoes)
|
||||
|
||||
# 5.0.1 (unreleased)
|
||||
|
||||
* **Postgres**: PostgreSQL error when using ipv6 address as hostname in connection string [#11055](https://github.com/grafana/grafana/issues/11055), thanks [@svenklemm](https://github.com/svenklemm)
|
||||
* **Dashboards**: Changing templated value from dropdown is causing unsaved changes [#11063](https://github.com/grafana/grafana/issues/11063)
|
||||
* **Prometheus**: Fixes bundled Prometheus 2.0 dashboard [#11016](https://github.com/grafana/grafana/issues/11016), thx [@roidelapluie](https://github.com/roidelapluie)
|
||||
* **Sidemenu**: Profile menu "invisible" when gravatar is disabled [#11097](https://github.com/grafana/grafana/issues/11097)
|
||||
* **Dashboard**: Fixes a bug with resizeable handles for panels [#11103](https://github.com/grafana/grafana/issues/11103)
|
||||
* **Alerting**: Telegram inline image mode fails when caption too long [#10975](https://github.com/grafana/grafana/issues/10975)
|
||||
* **Alerting**: Fixes silent failing validation [#11145](https://github.com/grafana/grafana/pull/11145)
|
||||
* **OAuth**: Only use jwt token if it contains an email address [#11127](https://github.com/grafana/grafana/pull/11127)
|
||||
|
||||
# 5.0.0-stable (2018-03-01)
|
||||
|
||||
|
@ -33,7 +33,7 @@ the latest master builds [here](https://grafana.com/grafana/download)
|
||||
### Building the backend
|
||||
```bash
|
||||
go get github.com/grafana/grafana
|
||||
cd ~/go/src/github.com/grafana/grafana
|
||||
cd $GOPATH/src/github.com/grafana/grafana
|
||||
go run build.go setup
|
||||
go run build.go build
|
||||
```
|
||||
|
@ -8,7 +8,6 @@ But it will give you an idea of our current vision and plan.
|
||||
- v5.1
|
||||
- Crossplatform builds & build speed improvements
|
||||
- Enterprise LDAP
|
||||
- New template interpolation syntax
|
||||
- Provisioning workflow
|
||||
- First login registration view
|
||||
- IFQL Initial support
|
||||
|
@ -9,7 +9,7 @@ machine:
|
||||
GOPATH: "/home/ubuntu/.go_workspace"
|
||||
ORG_PATH: "github.com/grafana"
|
||||
REPO_PATH: "${ORG_PATH}/grafana"
|
||||
GODIST: "go1.9.3.linux-amd64.tar.gz"
|
||||
GODIST: "go1.10.linux-amd64.tar.gz"
|
||||
post:
|
||||
- mkdir -p ~/download
|
||||
- mkdir -p ~/docker
|
||||
|
@ -144,7 +144,7 @@ Since not all datasources have the same configuration settings we only have the
|
||||
| tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
|
||||
| graphiteVersion | string | Graphite | Graphite version |
|
||||
| timeInterval | string | Elastic, Influxdb & Prometheus | Lowest interval/step value that should be used for this data source |
|
||||
| esVersion | string | Elastic | Elasticsearch version |
|
||||
| esVersion | string | Elastic | Elasticsearch version as an number (2/5/56) |
|
||||
| timeField | string | Elastic | Which field that should be used as timestamp |
|
||||
| interval | string | Elastic | Index date time format |
|
||||
| authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
|
||||
|
@ -59,7 +59,7 @@ avg() OF query(A, 5m, now) IS BELOW 14
|
||||
```
|
||||
|
||||
- `avg()` Controls how the values for **each** series should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function.
|
||||
- `query(A, 5m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `5m, now` means 5 minutes from now to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes from now to 2 minutes from now. This is useful if you want to ignore the last 2 minutes of data.
|
||||
- `query(A, 5m, now)` The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `5m, now` means 5 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data.
|
||||
- `IS BELOW 14` Defines the type of threshold and the threshold value. You can click on `IS BELOW` to change the type of threshold.
|
||||
|
||||
The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially.
|
||||
|
@ -74,7 +74,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
|
||||
}
|
||||
|
||||
if setting.DisableGravatar {
|
||||
data.User.GravatarUrl = setting.AppSubUrl + "/public/img/transparent.png"
|
||||
data.User.GravatarUrl = setting.AppSubUrl + "/public/img/user_profile.png"
|
||||
}
|
||||
|
||||
if len(data.User.Name) == 0 {
|
||||
|
@ -143,10 +143,15 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json)
|
||||
|
||||
// validate
|
||||
_, err = NewRuleFromDBAlert(alert)
|
||||
if err == nil && alert.ValidToSave() {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if alert.ValidToSave() {
|
||||
alerts = append(alerts, alert)
|
||||
} else {
|
||||
return nil, err
|
||||
e.log.Debug("Invalid Alert Data. Dashboard, Org or Panel ID is not correct", "alertName", alert.Name, "panelId", alert.PanelId)
|
||||
return nil, m.ErrDashboardContainsInvalidAlertData
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -150,6 +150,22 @@ func TestAlertRuleExtraction(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Panel with id set to zero should return error", func() {
|
||||
panelWithIdZero, err := ioutil.ReadFile("./test-data/panel-with-id-0.json")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
dashJson, err := simplejson.NewJson([]byte(panelWithIdZero))
|
||||
So(err, ShouldBeNil)
|
||||
dash := m.NewDashboardFromJson(dashJson)
|
||||
extractor := NewDashAlertExtractor(dash, 1)
|
||||
|
||||
_, err = extractor.GetAlerts()
|
||||
|
||||
Convey("panel with id 0 should return error", func() {
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Parse alerts from dashboard without rows", func() {
|
||||
json, err := ioutil.ReadFile("./test-data/v5-dashboard.json")
|
||||
So(err, ShouldBeNil)
|
||||
|
@ -99,11 +99,16 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err
|
||||
return err
|
||||
}
|
||||
|
||||
customData := "Triggered metrics:\n\n"
|
||||
for _, evt := range evalContext.EvalMatches {
|
||||
customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value)
|
||||
}
|
||||
|
||||
bodyJSON := simplejson.New()
|
||||
bodyJSON.Set("message", evalContext.Rule.Name)
|
||||
bodyJSON.Set("source", "Grafana")
|
||||
bodyJSON.Set("alias", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10))
|
||||
bodyJSON.Set("description", fmt.Sprintf("%s - %s\n%s", evalContext.Rule.Name, ruleUrl, evalContext.Rule.Message))
|
||||
bodyJSON.Set("description", fmt.Sprintf("%s - %s\n%s\n%s", evalContext.Rule.Name, ruleUrl, evalContext.Rule.Message, customData))
|
||||
|
||||
details := simplejson.New()
|
||||
details.Set("url", ruleUrl)
|
||||
|
@ -12,6 +12,10 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
captionLengthLimit = 200
|
||||
)
|
||||
|
||||
var (
|
||||
telegramApiUrl string = "https://api.telegram.org/bot%s/%s"
|
||||
)
|
||||
@ -82,88 +86,81 @@ func NewTelegramNotifier(model *m.AlertNotification) (alerting.Notifier, error)
|
||||
}
|
||||
|
||||
func (this *TelegramNotifier) buildMessage(evalContext *alerting.EvalContext, sendImageInline bool) *m.SendWebhookSync {
|
||||
var imageFile *os.File
|
||||
var err error
|
||||
|
||||
if sendImageInline {
|
||||
imageFile, err = os.Open(evalContext.ImageOnDiskPath)
|
||||
defer imageFile.Close()
|
||||
if err != nil {
|
||||
sendImageInline = false // fall back to text message
|
||||
cmd, err := this.buildMessageInlineImage(evalContext)
|
||||
if err == nil {
|
||||
return cmd
|
||||
} else {
|
||||
this.log.Error("Could not generate Telegram message with inline image.", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
message := ""
|
||||
return this.buildMessageLinkedImage(evalContext)
|
||||
}
|
||||
|
||||
if sendImageInline {
|
||||
// Telegram's API does not allow HTML formatting for image captions.
|
||||
message = fmt.Sprintf("%s\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message)
|
||||
} else {
|
||||
message = fmt.Sprintf("<b>%s</b>\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message)
|
||||
}
|
||||
func (this *TelegramNotifier) buildMessageLinkedImage(evalContext *alerting.EvalContext) *m.SendWebhookSync {
|
||||
message := fmt.Sprintf("<b>%s</b>\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message)
|
||||
|
||||
ruleUrl, err := evalContext.GetRuleUrl()
|
||||
if err == nil {
|
||||
message = message + fmt.Sprintf("URL: %s\n", ruleUrl)
|
||||
}
|
||||
|
||||
if !sendImageInline {
|
||||
// only attach this if we are not sending it inline.
|
||||
if evalContext.ImagePublicUrl != "" {
|
||||
message = message + fmt.Sprintf("Image: %s\n", evalContext.ImagePublicUrl)
|
||||
}
|
||||
}
|
||||
|
||||
metrics := ""
|
||||
fieldLimitCount := 4
|
||||
for index, evt := range evalContext.EvalMatches {
|
||||
metrics += fmt.Sprintf("\n%s: %s", evt.Metric, evt.Value)
|
||||
if index > fieldLimitCount {
|
||||
break
|
||||
}
|
||||
if evalContext.ImagePublicUrl != "" {
|
||||
message = message + fmt.Sprintf("Image: %s\n", evalContext.ImagePublicUrl)
|
||||
}
|
||||
|
||||
metrics := generateMetricsMessage(evalContext)
|
||||
if metrics != "" {
|
||||
if sendImageInline {
|
||||
// Telegram's API does not allow HTML formatting for image captions.
|
||||
message = message + fmt.Sprintf("\nMetrics:%s", metrics)
|
||||
} else {
|
||||
message = message + fmt.Sprintf("\n<i>Metrics:</i>%s", metrics)
|
||||
}
|
||||
message = message + fmt.Sprintf("\n<i>Metrics:</i>%s", metrics)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
cmd := this.generateTelegramCmd(message, "text", "sendMessage", func(w *multipart.Writer) {
|
||||
fw, _ := w.CreateFormField("parse_mode")
|
||||
fw.Write([]byte("html"))
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.EvalContext) (*m.SendWebhookSync, error) {
|
||||
var imageFile *os.File
|
||||
var err error
|
||||
|
||||
imageFile, err = os.Open(evalContext.ImageOnDiskPath)
|
||||
defer imageFile.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ruleUrl, err := evalContext.GetRuleUrl()
|
||||
|
||||
metrics := generateMetricsMessage(evalContext)
|
||||
message := generateImageCaption(evalContext, ruleUrl, metrics)
|
||||
|
||||
cmd := this.generateTelegramCmd(message, "caption", "sendPhoto", func(w *multipart.Writer) {
|
||||
fw, _ := w.CreateFormFile("photo", evalContext.ImageOnDiskPath)
|
||||
io.Copy(fw, imageFile)
|
||||
})
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func (this *TelegramNotifier) generateTelegramCmd(message string, messageField string, apiAction string, extraConf func(writer *multipart.Writer)) *m.SendWebhookSync {
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
|
||||
fw, _ := w.CreateFormField("chat_id")
|
||||
fw.Write([]byte(this.ChatID))
|
||||
|
||||
if sendImageInline {
|
||||
fw, _ = w.CreateFormField("caption")
|
||||
fw.Write([]byte(message))
|
||||
fw, _ = w.CreateFormField(messageField)
|
||||
fw.Write([]byte(message))
|
||||
|
||||
fw, _ = w.CreateFormFile("photo", evalContext.ImageOnDiskPath)
|
||||
io.Copy(fw, imageFile)
|
||||
} else {
|
||||
fw, _ = w.CreateFormField("text")
|
||||
fw.Write([]byte(message))
|
||||
|
||||
fw, _ = w.CreateFormField("parse_mode")
|
||||
fw.Write([]byte("html"))
|
||||
}
|
||||
extraConf(w)
|
||||
|
||||
w.Close()
|
||||
|
||||
apiMethod := ""
|
||||
if sendImageInline {
|
||||
this.log.Info("Sending telegram image notification", "photo", evalContext.ImageOnDiskPath, "chat_id", this.ChatID, "bot_token", this.BotToken)
|
||||
apiMethod = "sendPhoto"
|
||||
} else {
|
||||
this.log.Info("Sending telegram text notification", "chat_id", this.ChatID, "bot_token", this.BotToken)
|
||||
apiMethod = "sendMessage"
|
||||
}
|
||||
this.log.Info("Sending telegram notification", "chat_id", this.ChatID, "bot_token", this.BotToken, "apiAction", apiAction)
|
||||
url := fmt.Sprintf(telegramApiUrl, this.BotToken, apiAction)
|
||||
|
||||
url := fmt.Sprintf(telegramApiUrl, this.BotToken, apiMethod)
|
||||
cmd := &m.SendWebhookSync{
|
||||
Url: url,
|
||||
Body: body.String(),
|
||||
@ -175,6 +172,50 @@ func (this *TelegramNotifier) buildMessage(evalContext *alerting.EvalContext, se
|
||||
return cmd
|
||||
}
|
||||
|
||||
func generateMetricsMessage(evalContext *alerting.EvalContext) string {
|
||||
metrics := ""
|
||||
fieldLimitCount := 4
|
||||
for index, evt := range evalContext.EvalMatches {
|
||||
metrics += fmt.Sprintf("\n%s: %s", evt.Metric, evt.Value)
|
||||
if index > fieldLimitCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func generateImageCaption(evalContext *alerting.EvalContext, ruleUrl string, metrics string) string {
|
||||
message := evalContext.GetNotificationTitle()
|
||||
|
||||
if len(evalContext.Rule.Message) > 0 {
|
||||
message = fmt.Sprintf("%s\nMessage: %s", message, evalContext.Rule.Message)
|
||||
}
|
||||
|
||||
if len(message) > captionLengthLimit {
|
||||
message = message[0:captionLengthLimit]
|
||||
|
||||
}
|
||||
|
||||
if len(ruleUrl) > 0 {
|
||||
urlLine := fmt.Sprintf("\nURL: %s", ruleUrl)
|
||||
message = appendIfPossible(message, urlLine, captionLengthLimit)
|
||||
}
|
||||
|
||||
if metrics != "" {
|
||||
metricsLines := fmt.Sprintf("\n\nMetrics:%s", metrics)
|
||||
message = appendIfPossible(message, metricsLines, captionLengthLimit)
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
func appendIfPossible(message string, extra string, sizeLimit int) string {
|
||||
if len(extra)+len(message) <= sizeLimit {
|
||||
return message + extra
|
||||
}
|
||||
log.Debug("Line too long for image caption.", "value", extra)
|
||||
return message
|
||||
}
|
||||
|
||||
func (this *TelegramNotifier) ShouldNotify(context *alerting.EvalContext) bool {
|
||||
return defaultShouldNotify(context)
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@ -50,6 +51,71 @@ func TestTelegramNotifier(t *testing.T) {
|
||||
So(telegramNotifier.ChatID, ShouldEqual, "-1234567890")
|
||||
})
|
||||
|
||||
Convey("generateCaption should generate a message with all pertinent details", func() {
|
||||
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
|
||||
Name: "This is an alarm",
|
||||
Message: "Some kind of message.",
|
||||
State: m.AlertStateOK,
|
||||
})
|
||||
|
||||
caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "")
|
||||
So(len(caption), ShouldBeLessThanOrEqualTo, 200)
|
||||
So(caption, ShouldContainSubstring, "Some kind of message.")
|
||||
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
|
||||
So(caption, ShouldContainSubstring, "http://grafa.url/abcdef")
|
||||
})
|
||||
|
||||
Convey("When generating a message", func() {
|
||||
|
||||
Convey("URL should be skipped if it's too long", func() {
|
||||
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
|
||||
Name: "This is an alarm",
|
||||
Message: "Some kind of message.",
|
||||
State: m.AlertStateOK,
|
||||
})
|
||||
|
||||
caption := generateImageCaption(evalContext,
|
||||
"http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"foo bar")
|
||||
So(len(caption), ShouldBeLessThanOrEqualTo, 200)
|
||||
So(caption, ShouldContainSubstring, "Some kind of message.")
|
||||
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
|
||||
So(caption, ShouldContainSubstring, "foo bar")
|
||||
So(caption, ShouldNotContainSubstring, "http")
|
||||
})
|
||||
|
||||
Convey("Message should be trimmed if it's too long", func() {
|
||||
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
|
||||
Name: "This is an alarm",
|
||||
Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.",
|
||||
State: m.AlertStateOK,
|
||||
})
|
||||
|
||||
caption := generateImageCaption(evalContext,
|
||||
"http://grafa.url/foo",
|
||||
"")
|
||||
So(len(caption), ShouldBeLessThanOrEqualTo, 200)
|
||||
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
|
||||
So(caption, ShouldNotContainSubstring, "http")
|
||||
So(caption, ShouldContainSubstring, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise ")
|
||||
})
|
||||
|
||||
Convey("Metrics should be skipped if they dont fit", func() {
|
||||
evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
|
||||
Name: "This is an alarm",
|
||||
Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ",
|
||||
State: m.AlertStateOK,
|
||||
})
|
||||
|
||||
caption := generateImageCaption(evalContext,
|
||||
"http://grafa.url/foo",
|
||||
"foo bar long song")
|
||||
So(len(caption), ShouldBeLessThanOrEqualTo, 200)
|
||||
So(caption, ShouldContainSubstring, "[OK] This is an alarm")
|
||||
So(caption, ShouldNotContainSubstring, "http")
|
||||
So(caption, ShouldNotContainSubstring, "foo bar")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
63
pkg/services/alerting/test-data/panel-with-id-0.json
Normal file
63
pkg/services/alerting/test-data/panel-with-id-0.json
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"id": 57,
|
||||
"title": "Graphite 4",
|
||||
"originalTitle": "Graphite 4",
|
||||
"tags": ["graphite"],
|
||||
"rows": [
|
||||
{
|
||||
"panels": [
|
||||
{
|
||||
"title": "Active desktop users",
|
||||
"id": 0,
|
||||
"editable": true,
|
||||
"type": "graph",
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)"
|
||||
}
|
||||
],
|
||||
"datasource": null,
|
||||
"alert": {
|
||||
"name": "name1",
|
||||
"message": "desc1",
|
||||
"handler": 1,
|
||||
"frequency": "60s",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "query",
|
||||
"query": {"params": ["A", "5m", "now"]},
|
||||
"reducer": {"type": "avg", "params": []},
|
||||
"evaluator": {"type": ">", "params": [100]}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"message": "desc2",
|
||||
"handler": 0,
|
||||
"frequency": "60s",
|
||||
"severity": "warning",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "query",
|
||||
"query": {"params": ["B", "5m", "now"]},
|
||||
"reducer": {"type": "avg", "params": []},
|
||||
"evaluator": {"type": ">", "params": [100]}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -10,6 +10,9 @@ import (
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
// timeNow makes it possible to test usage of time
|
||||
var timeNow = time.Now
|
||||
|
||||
func init() {
|
||||
bus.AddHandler("sql", SaveAlerts)
|
||||
bus.AddHandler("sql", HandleAlertsQuery)
|
||||
@ -147,7 +150,7 @@ func SaveAlerts(cmd *m.SaveAlertsCommand) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := upsertAlerts(existingAlerts, cmd, sess); err != nil {
|
||||
if err := updateAlerts(existingAlerts, cmd, sess); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -159,7 +162,7 @@ func SaveAlerts(cmd *m.SaveAlertsCommand) error {
|
||||
})
|
||||
}
|
||||
|
||||
func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBSession) error {
|
||||
func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBSession) error {
|
||||
for _, alert := range cmd.Alerts {
|
||||
update := false
|
||||
var alertToUpdate *m.Alert
|
||||
@ -175,7 +178,7 @@ func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS
|
||||
|
||||
if update {
|
||||
if alertToUpdate.ContainsUpdates(alert) {
|
||||
alert.Updated = time.Now()
|
||||
alert.Updated = timeNow()
|
||||
alert.State = alertToUpdate.State
|
||||
sess.MustCols("message")
|
||||
_, err := sess.Id(alert.Id).Update(alert)
|
||||
@ -186,10 +189,10 @@ func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS
|
||||
sqlog.Debug("Alert updated", "name", alert.Name, "id", alert.Id)
|
||||
}
|
||||
} else {
|
||||
alert.Updated = time.Now()
|
||||
alert.Created = time.Now()
|
||||
alert.Updated = timeNow()
|
||||
alert.Created = timeNow()
|
||||
alert.State = m.AlertStatePending
|
||||
alert.NewStateDate = time.Now()
|
||||
alert.NewStateDate = timeNow()
|
||||
|
||||
_, err := sess.Insert(alert)
|
||||
if err != nil {
|
||||
@ -253,7 +256,7 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error {
|
||||
|
||||
alert.State = cmd.State
|
||||
alert.StateChanges += 1
|
||||
alert.NewStateDate = time.Now()
|
||||
alert.NewStateDate = timeNow()
|
||||
alert.EvalData = cmd.EvalData
|
||||
|
||||
if cmd.Error == "" {
|
||||
@ -276,11 +279,13 @@ func PauseAlert(cmd *m.PauseAlertCommand) error {
|
||||
var buffer bytes.Buffer
|
||||
params := make([]interface{}, 0)
|
||||
|
||||
buffer.WriteString(`UPDATE alert SET state = ?`)
|
||||
buffer.WriteString(`UPDATE alert SET state = ?, new_state_date = ?`)
|
||||
if cmd.Paused {
|
||||
params = append(params, string(m.AlertStatePaused))
|
||||
params = append(params, timeNow())
|
||||
} else {
|
||||
params = append(params, string(m.AlertStatePending))
|
||||
params = append(params, timeNow())
|
||||
}
|
||||
|
||||
buffer.WriteString(` WHERE id IN (?` + strings.Repeat(",?", len(cmd.AlertIds)-1) + `)`)
|
||||
@ -306,7 +311,7 @@ func PauseAllAlerts(cmd *m.PauseAllAlertCommand) error {
|
||||
newState = string(m.AlertStatePending)
|
||||
}
|
||||
|
||||
res, err := sess.Exec(`UPDATE alert SET state = ?`, newState)
|
||||
res, err := sess.Exec(`UPDATE alert SET state = ?, new_state_date = ?`, newState, timeNow())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -6,9 +6,26 @@ import (
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mockTimeNow() {
|
||||
var timeSeed int64
|
||||
timeNow = func() time.Time {
|
||||
fakeNow := time.Unix(timeSeed, 0)
|
||||
timeSeed += 1
|
||||
return fakeNow
|
||||
}
|
||||
}
|
||||
|
||||
func resetTimeNow() {
|
||||
timeNow = time.Now
|
||||
}
|
||||
|
||||
func TestAlertingDataAccess(t *testing.T) {
|
||||
mockTimeNow()
|
||||
defer resetTimeNow()
|
||||
|
||||
Convey("Testing Alerting data access", t, func() {
|
||||
InitTestDB(t)
|
||||
|
||||
@ -50,13 +67,11 @@ func TestAlertingDataAccess(t *testing.T) {
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("can pause alert", func() {
|
||||
cmd := &m.PauseAllAlertCommand{
|
||||
Paused: true,
|
||||
}
|
||||
alert, _ := getAlertById(1)
|
||||
stateDateBeforePause := alert.NewStateDate
|
||||
|
||||
err = PauseAllAlerts(cmd)
|
||||
So(err, ShouldBeNil)
|
||||
Convey("can pause all alerts", func() {
|
||||
pauseAllAlerts(true)
|
||||
|
||||
Convey("cannot updated paused alert", func() {
|
||||
cmd := &m.SetAlertStateCommand{
|
||||
@ -67,6 +82,19 @@ func TestAlertingDataAccess(t *testing.T) {
|
||||
err = SetAlertState(cmd)
|
||||
So(err, ShouldNotBeNil)
|
||||
})
|
||||
|
||||
Convey("pausing alerts should update their NewStateDate", func() {
|
||||
alert, _ = getAlertById(1)
|
||||
stateDateAfterPause := alert.NewStateDate
|
||||
So(stateDateBeforePause, ShouldHappenBefore, stateDateAfterPause)
|
||||
})
|
||||
|
||||
Convey("unpausing alerts should update their NewStateDate again", func() {
|
||||
pauseAllAlerts(false)
|
||||
alert, _ = getAlertById(1)
|
||||
stateDateAfterUnpause := alert.NewStateDate
|
||||
So(stateDateBeforePause, ShouldHappenBefore, stateDateAfterUnpause)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -214,3 +242,90 @@ func TestAlertingDataAccess(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestPausingAlerts(t *testing.T) {
|
||||
mockTimeNow()
|
||||
defer resetTimeNow()
|
||||
|
||||
Convey("Given an alert", t, func() {
|
||||
InitTestDB(t)
|
||||
|
||||
testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert")
|
||||
alert, _ := insertTestAlert("Alerting title", "Alerting message", testDash.OrgId, testDash.Id, simplejson.New())
|
||||
|
||||
stateDateBeforePause := alert.NewStateDate
|
||||
stateDateAfterPause := stateDateBeforePause
|
||||
Convey("when paused", func() {
|
||||
pauseAlert(testDash.OrgId, 1, true)
|
||||
|
||||
Convey("the NewStateDate should be updated", func() {
|
||||
alert, _ := getAlertById(1)
|
||||
|
||||
stateDateAfterPause = alert.NewStateDate
|
||||
So(stateDateBeforePause, ShouldHappenBefore, stateDateAfterPause)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("when unpaused", func() {
|
||||
pauseAlert(testDash.OrgId, 1, false)
|
||||
|
||||
Convey("the NewStateDate should be updated again", func() {
|
||||
alert, _ := getAlertById(1)
|
||||
|
||||
stateDateAfterUnpause := alert.NewStateDate
|
||||
So(stateDateAfterPause, ShouldHappenBefore, stateDateAfterUnpause)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
func pauseAlert(orgId int64, alertId int64, pauseState bool) (int64, error) {
|
||||
cmd := &m.PauseAlertCommand{
|
||||
OrgId: orgId,
|
||||
AlertIds: []int64{alertId},
|
||||
Paused: pauseState,
|
||||
}
|
||||
err := PauseAlert(cmd)
|
||||
So(err, ShouldBeNil)
|
||||
return cmd.ResultCount, err
|
||||
}
|
||||
func insertTestAlert(title string, message string, orgId int64, dashId int64, settings *simplejson.Json) (*m.Alert, error) {
|
||||
items := []*m.Alert{
|
||||
{
|
||||
PanelId: 1,
|
||||
DashboardId: dashId,
|
||||
OrgId: orgId,
|
||||
Name: title,
|
||||
Message: message,
|
||||
Settings: settings,
|
||||
Frequency: 1,
|
||||
},
|
||||
}
|
||||
|
||||
cmd := m.SaveAlertsCommand{
|
||||
Alerts: items,
|
||||
DashboardId: dashId,
|
||||
OrgId: orgId,
|
||||
UserId: 1,
|
||||
}
|
||||
|
||||
err := SaveAlerts(&cmd)
|
||||
return cmd.Alerts[0], err
|
||||
}
|
||||
|
||||
func getAlertById(id int64) (*m.Alert, error) {
|
||||
q := &m.GetAlertByIdQuery{
|
||||
Id: id,
|
||||
}
|
||||
err := GetAlertById(q)
|
||||
So(err, ShouldBeNil)
|
||||
return q.Result, err
|
||||
}
|
||||
|
||||
func pauseAllAlerts(pauseState bool) error {
|
||||
cmd := &m.PauseAllAlertCommand{
|
||||
Paused: pauseState,
|
||||
}
|
||||
err := PauseAllAlerts(cmd)
|
||||
So(err, ShouldBeNil)
|
||||
return err
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ package sqlstore
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-xorm/xorm"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
@ -11,10 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDashboardFolderDataAccess(t *testing.T) {
|
||||
var x *xorm.Engine
|
||||
|
||||
Convey("Testing DB", t, func() {
|
||||
x = InitTestDB(t)
|
||||
InitTestDB(t)
|
||||
|
||||
Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
|
||||
folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
|
||||
|
@ -5,7 +5,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-xorm/xorm"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
m "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/search"
|
||||
@ -15,10 +14,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDashboardDataAccess(t *testing.T) {
|
||||
var x *xorm.Engine
|
||||
|
||||
Convey("Testing DB", t, func() {
|
||||
x = InitTestDB(t)
|
||||
InitTestDB(t)
|
||||
|
||||
Convey("Given saved dashboard", func() {
|
||||
savedFolder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
|
||||
|
@ -180,6 +180,7 @@ type UserInfoJson struct {
|
||||
|
||||
func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
|
||||
var data UserInfoJson
|
||||
var err error
|
||||
|
||||
if s.extractToken(&data, token) != true {
|
||||
response, err := HttpGet(client, s.apiUrl)
|
||||
@ -193,20 +194,17 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token)
|
||||
}
|
||||
}
|
||||
|
||||
name, err := s.extractName(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
name := s.extractName(&data)
|
||||
|
||||
email := s.extractEmail(&data)
|
||||
if email == "" {
|
||||
email, err = s.FetchPrivateEmail(client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
email, err := s.extractEmail(data, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
login, err := s.extractLogin(data, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
login := s.extractLogin(&data, email)
|
||||
|
||||
userInfo := &BasicUserInfo{
|
||||
Name: name,
|
||||
@ -251,49 +249,55 @@ func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Toke
|
||||
return false
|
||||
}
|
||||
|
||||
email := s.extractEmail(data)
|
||||
if email == "" {
|
||||
s.log.Debug("No email found in id_token", "json", string(payload), "data", data)
|
||||
return false
|
||||
}
|
||||
|
||||
s.log.Debug("Received id_token", "json", string(payload), "data", data)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *SocialGenericOAuth) extractEmail(data UserInfoJson, client *http.Client) (string, error) {
|
||||
func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
|
||||
if data.Email != "" {
|
||||
return data.Email, nil
|
||||
return data.Email
|
||||
}
|
||||
|
||||
if data.Attributes["email:primary"] != nil {
|
||||
return data.Attributes["email:primary"][0], nil
|
||||
return data.Attributes["email:primary"][0]
|
||||
}
|
||||
|
||||
if data.Upn != "" {
|
||||
emailAddr, emailErr := mail.ParseAddress(data.Upn)
|
||||
if emailErr == nil {
|
||||
return emailAddr.Address, nil
|
||||
return emailAddr.Address
|
||||
}
|
||||
}
|
||||
|
||||
return s.FetchPrivateEmail(client)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SocialGenericOAuth) extractLogin(data UserInfoJson, email string) (string, error) {
|
||||
func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson, email string) string {
|
||||
if data.Login != "" {
|
||||
return data.Login, nil
|
||||
return data.Login
|
||||
}
|
||||
|
||||
if data.Username != "" {
|
||||
return data.Username, nil
|
||||
return data.Username
|
||||
}
|
||||
|
||||
return email, nil
|
||||
return email
|
||||
}
|
||||
|
||||
func (s *SocialGenericOAuth) extractName(data UserInfoJson) (string, error) {
|
||||
func (s *SocialGenericOAuth) extractName(data *UserInfoJson) string {
|
||||
if data.Name != "" {
|
||||
return data.Name, nil
|
||||
return data.Name
|
||||
}
|
||||
|
||||
if data.DisplayName != "" {
|
||||
return data.DisplayName, nil
|
||||
return data.DisplayName
|
||||
}
|
||||
|
||||
return "", nil
|
||||
return ""
|
||||
}
|
||||
|
@ -22,23 +22,10 @@ import (
|
||||
)
|
||||
|
||||
type OpenTsdbExecutor struct {
|
||||
//*models.DataSource
|
||||
//httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewOpenTsdbExecutor(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
|
||||
/*
|
||||
httpClient, err := datasource.GetHttpClient()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*/
|
||||
|
||||
return &OpenTsdbExecutor{
|
||||
//DataSource: datasource,
|
||||
//httpClient: httpClient,
|
||||
}, nil
|
||||
return &OpenTsdbExecutor{}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -8,6 +8,7 @@ export function geminiScrollbar() {
|
||||
link: function(scope, elem, attrs) {
|
||||
let scrollbar = new PerfectScrollbar(elem[0], {
|
||||
wheelPropagation: true,
|
||||
wheelSpeed: 3,
|
||||
});
|
||||
let lastPos = 0;
|
||||
|
||||
|
@ -1,73 +1,78 @@
|
||||
<a class="sidemenu__logo" ng-click="ctrl.toggleSideMenu()">
|
||||
<img src="public/img/grafana_icon.svg"></img>
|
||||
<img src="public/img/grafana_icon.svg"></img>
|
||||
</a>
|
||||
|
||||
<a class="sidemenu__logo_small_breakpoint" ng-click="ctrl.toggleSideMenuSmallBreakpoint()">
|
||||
<i class="fa fa-bars"></i>
|
||||
<span class="sidemenu__close"><i class="fa fa-times"></i> Close</span>
|
||||
<span class="sidemenu__close">
|
||||
<i class="fa fa-times"></i> Close</span>
|
||||
</a>
|
||||
|
||||
<div class="sidemenu__top">
|
||||
<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
|
||||
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="{{::item.icon}}" ng-show="::item.icon"></i>
|
||||
<img ng-src="{{::item.img}}" ng-show="::item.img">
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu" ng-if="::item.children">
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">{{::item.text}}</span>
|
||||
</li>
|
||||
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}">
|
||||
<a href="{{::child.url}}">
|
||||
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
|
||||
{{::child.text}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
|
||||
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="{{::item.icon}}" ng-show="::item.icon"></i>
|
||||
<img ng-src="{{::item.img}}" ng-show="::item.img">
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu" ng-if="::item.children">
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">{{::item.text}}</span>
|
||||
</li>
|
||||
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}">
|
||||
<a href="{{::child.url}}">
|
||||
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
|
||||
{{::child.text}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidemenu__bottom">
|
||||
<div ng-show="::!ctrl.isSignedIn" class="sidemenu-item">
|
||||
<a href="{{ctrl.loginUrl}}" class="sidemenu-link" target="_self">
|
||||
<span class="icon-circle sidemenu-icon"><i class="fa fa-fw fa-sign-in"></i></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">Sign In</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div ng-show="::!ctrl.isSignedIn" class="sidemenu-item">
|
||||
<a href="{{ctrl.loginUrl}}" class="sidemenu-link" target="_self">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="fa fa-fw fa-sign-in"></i>
|
||||
</span>
|
||||
</a>
|
||||
<a href="{{ctrl.loginUrl}}">
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">Sign In</span>
|
||||
</li>
|
||||
</ul>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="item in ::ctrl.bottomNav" class="sidemenu-item dropdown dropup">
|
||||
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="{{::item.icon}}" ng-show="::item.icon"></i>
|
||||
<img ng-src="{{::item.img}}" ng-show="::item.img">
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<li ng-if="item.showOrgSwitcher" class="sidemenu-org-switcher">
|
||||
<a ng-click="ctrl.switchOrg()">
|
||||
<div>
|
||||
<div class="sidemenu-org-switcher__org-name">{{ctrl.contextSrv.user.orgName}}</div>
|
||||
<div class="sidemenu-org-switcher__org-current">Current Org:</div>
|
||||
</div>
|
||||
<div class="sidemenu-org-switcher__switch"><i class="fa fa-fw fa-random"></i>Switch</div>
|
||||
</a>
|
||||
</li>
|
||||
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
|
||||
<a href="{{::child.url}}" target="{{::child.target}}" ng-click="ctrl.itemClicked(child, $event)">
|
||||
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
|
||||
{{::child.text}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">{{::item.text}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div ng-repeat="item in ::ctrl.bottomNav" class="sidemenu-item dropdown dropup">
|
||||
<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
|
||||
<span class="icon-circle sidemenu-icon">
|
||||
<i class="{{::item.icon}}" ng-show="::item.icon"></i>
|
||||
<img ng-src="{{::item.img}}" ng-show="::item.img">
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
|
||||
<li ng-if="item.showOrgSwitcher" class="sidemenu-org-switcher">
|
||||
<a ng-click="ctrl.switchOrg()">
|
||||
<div>
|
||||
<div class="sidemenu-org-switcher__org-name">{{ctrl.contextSrv.user.orgName}}</div>
|
||||
<div class="sidemenu-org-switcher__org-current">Current Org:</div>
|
||||
</div>
|
||||
<div class="sidemenu-org-switcher__switch">
|
||||
<i class="fa fa-fw fa-random"></i>Switch</div>
|
||||
</a>
|
||||
</li>
|
||||
<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
|
||||
<a href="{{::child.url}}" target="{{::child.target}}" ng-click="ctrl.itemClicked(child, $event)">
|
||||
<i class="{{::child.icon}}" ng-show="::child.icon"></i>
|
||||
{{::child.text}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="side-menu-header">
|
||||
<span class="sidemenu-item-text">{{::item.text}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { PanelModel } from '../panel_model';
|
||||
import { PanelContainer } from './PanelContainer';
|
||||
import templateSrv from 'app/features/templating/template_srv';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import config from 'app/core/config';
|
||||
|
||||
export interface DashboardRowProps {
|
||||
panel: PanelModel;
|
||||
@ -94,14 +95,16 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
|
||||
{title}
|
||||
<span className="dashboard-row__panel_count">({hiddenPanels} hidden panels)</span>
|
||||
</a>
|
||||
<div className="dashboard-row__actions">
|
||||
<a className="pointer" onClick={this.openSettings}>
|
||||
<i className="fa fa-cog" />
|
||||
</a>
|
||||
<a className="pointer" onClick={this.delete}>
|
||||
<i className="fa fa-trash" />
|
||||
</a>
|
||||
</div>
|
||||
{config.bootData.user.orgRole !== 'Viewer' && (
|
||||
<div className="dashboard-row__actions">
|
||||
<a className="pointer" onClick={this.openSettings}>
|
||||
<i className="fa fa-cog" />
|
||||
</a>
|
||||
<a className="pointer" onClick={this.delete}>
|
||||
<i className="fa fa-trash" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="dashboard-row__drag grid-drag-handle" />
|
||||
</div>
|
||||
);
|
||||
|
@ -2,19 +2,26 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { DashboardRow } from '../dashgrid/DashboardRow';
|
||||
import { PanelModel } from '../panel_model';
|
||||
import config from '../../../core/config';
|
||||
|
||||
describe('DashboardRow', () => {
|
||||
let wrapper, panel, getPanelContainer, dashboardMock;
|
||||
|
||||
beforeEach(() => {
|
||||
dashboardMock = {toggleRow: jest.fn()};
|
||||
dashboardMock = { toggleRow: jest.fn() };
|
||||
|
||||
config.bootData = {
|
||||
user: {
|
||||
orgRole: 'Admin',
|
||||
},
|
||||
};
|
||||
|
||||
getPanelContainer = jest.fn().mockReturnValue({
|
||||
getDashboard: jest.fn().mockReturnValue(dashboardMock),
|
||||
getPanelLoader: jest.fn()
|
||||
getPanelLoader: jest.fn(),
|
||||
});
|
||||
|
||||
panel = new PanelModel({collapsed: false});
|
||||
panel = new PanelModel({ collapsed: false });
|
||||
wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
|
||||
});
|
||||
|
||||
@ -30,4 +37,14 @@ describe('DashboardRow', () => {
|
||||
expect(dashboardMock.toggleRow.mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should have two actions as admin', () => {
|
||||
expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should have zero actions as viewer', () => {
|
||||
config.bootData.user.orgRole = 'Viewer';
|
||||
panel = new PanelModel({ collapsed: false });
|
||||
wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
|
||||
expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
@ -107,7 +107,6 @@ describe('templateSrv', function() {
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should replace $test with globbed value', function() {
|
||||
var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
|
||||
expect(target).toBe('this.{value1,value2}.filters');
|
||||
@ -261,6 +260,11 @@ describe('templateSrv', function() {
|
||||
expect(result).toBe('test');
|
||||
});
|
||||
|
||||
it('multi value and csv format should render csv string', function() {
|
||||
var result = _templateSrv.formatValue(['test', 'test2'], 'csv');
|
||||
expect(result).toBe('test,test2');
|
||||
});
|
||||
|
||||
it('slash should be properly escaped in regex format', function() {
|
||||
var result = _templateSrv.formatValue('Gi3/14', 'regex');
|
||||
expect(result).toBe('Gi3\\/14');
|
||||
|
@ -115,6 +115,12 @@ export class TemplateSrv {
|
||||
}
|
||||
return this.distributeVariable(value, variable.name);
|
||||
}
|
||||
case 'csv': {
|
||||
if (_.isArray(value)) {
|
||||
return value.join(',');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
default: {
|
||||
if (_.isArray(value)) {
|
||||
return '{' + value.join(',') + '}';
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from '../../../../../test/lib/common';
|
||||
|
||||
import angular from 'angular';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
import { ThresholdManager } from '../threshold_manager';
|
||||
|
||||
describe('ThresholdManager', function() {
|
||||
@ -15,9 +17,13 @@ describe('ThresholdManager', function() {
|
||||
panelCtrl: {},
|
||||
};
|
||||
|
||||
ctx.setup = function(thresholds) {
|
||||
ctx.setup = function(thresholds, data) {
|
||||
ctx.panel.thresholds = thresholds;
|
||||
var manager = new ThresholdManager(ctx.panelCtrl);
|
||||
if (data !== undefined) {
|
||||
var element = angular.element('<div grafana-graph><div>');
|
||||
manager.prepare(element, data);
|
||||
}
|
||||
manager.addFlotOptions(ctx.options, ctx.panel);
|
||||
};
|
||||
|
||||
@ -101,5 +107,36 @@ describe('ThresholdManager', function() {
|
||||
expect(markings[1].yaxis.to).to.be(-Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
plotOptionsScenario('for threshold on two Y axes', ctx => {
|
||||
var data = new Array(2);
|
||||
data[0] = new TimeSeries({
|
||||
datapoints: [[0, 1], [300, 2]],
|
||||
alias: 'left',
|
||||
});
|
||||
data[0].yaxis = 1;
|
||||
data[1] = new TimeSeries({
|
||||
datapoints: [[0, 1], [300, 2]],
|
||||
alias: 'right',
|
||||
});
|
||||
data[1].yaxis = 2;
|
||||
ctx.setup(
|
||||
[
|
||||
{ op: 'gt', value: 100, line: true, colorMode: 'critical' },
|
||||
{ op: 'gt', value: 200, line: true, colorMode: 'critical', yaxis: 'right' },
|
||||
],
|
||||
data
|
||||
);
|
||||
|
||||
it('should add first threshold for left axis', function() {
|
||||
var markings = ctx.options.grid.markings;
|
||||
expect(markings[0].yaxis.from).to.be(100);
|
||||
});
|
||||
|
||||
it('should add second threshold for right axis', function() {
|
||||
var markings = ctx.options.grid.markings;
|
||||
expect(markings[1].y2axis.from).to.be(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -222,16 +222,30 @@ export class ThresholdManager {
|
||||
|
||||
// fill
|
||||
if (threshold.fill) {
|
||||
options.grid.markings.push({
|
||||
yaxis: { from: threshold.value, to: limit },
|
||||
color: fillColor,
|
||||
});
|
||||
if (threshold.yaxis === 'right' && this.hasSecondYAxis) {
|
||||
options.grid.markings.push({
|
||||
y2axis: { from: threshold.value, to: limit },
|
||||
color: fillColor,
|
||||
});
|
||||
} else {
|
||||
options.grid.markings.push({
|
||||
yaxis: { from: threshold.value, to: limit },
|
||||
color: fillColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (threshold.line) {
|
||||
options.grid.markings.push({
|
||||
yaxis: { from: threshold.value, to: threshold.value },
|
||||
color: lineColor,
|
||||
});
|
||||
if (threshold.yaxis === 'right' && this.hasSecondYAxis) {
|
||||
options.grid.markings.push({
|
||||
y2axis: { from: threshold.value, to: threshold.value },
|
||||
color: lineColor,
|
||||
});
|
||||
} else {
|
||||
options.grid.markings.push({
|
||||
yaxis: { from: threshold.value, to: threshold.value },
|
||||
color: lineColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ export class ThresholdFormCtrl {
|
||||
op: 'gt',
|
||||
fill: true,
|
||||
line: true,
|
||||
yaxis: 'left',
|
||||
});
|
||||
this.panelCtrl.render();
|
||||
}
|
||||
@ -109,6 +110,16 @@ var template = `
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label">Y-Axis</label>
|
||||
<div class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input" ng-model="threshold.yaxis"
|
||||
ng-init="threshold.yaxis = threshold.yaxis === 'left' || threshold.yaxis === 'right' ? threshold.yaxis : 'left'"
|
||||
ng-options="f for f in ['left', 'right']" ng-change="ctrl.render()" ng-disabled="ctrl.disabled">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-label">
|
||||
<a class="pointer" ng-click="ctrl.removeThreshold($index)" ng-disabled="ctrl.disabled">
|
||||
|
11
public/img/resize-handle-white.svg
Normal file
11
public/img/resize-handle-white.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!-- Generator: Adobe Fireworks CS6, Export SVG Extension by Aaron Beall (http://fireworks.abeall.com) . Version: 0.6.1 -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg id="Untitled-Page%201" viewBox="0 0 6 6" style="background-color:#ffffff00" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
|
||||
x="0px" y="0px" width="6px" height="6px"
|
||||
>
|
||||
<g opacity="0.302">
|
||||
<path d="M 6 6 L 0 6 L 0 4.2 L 4 4.2 L 4.2 4.2 L 4.2 0 L 6 0 L 6 6 L 6 6 Z" fill="#FFFFFF"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 630 B |
@ -259,6 +259,7 @@ $navbar-button-border: #2f2f32;
|
||||
// Sidemenu
|
||||
// -------------------------
|
||||
$side-menu-bg: $black;
|
||||
$side-menu-bg-mobile: $side-menu-bg;
|
||||
$side-menu-item-hover-bg: $dark-2;
|
||||
$side-menu-shadow: 0 0 20px black;
|
||||
$side-menu-link-color: $link-color;
|
||||
|
@ -200,6 +200,7 @@ $input-invalid-border-color: lighten($red, 5%);
|
||||
// Sidemenu
|
||||
// -------------------------
|
||||
$side-menu-bg: $dark-2;
|
||||
$side-menu-bg-mobile: rgba(0, 0, 0, 0); //$gray-6;
|
||||
$side-menu-item-hover-bg: $gray-1;
|
||||
$side-menu-shadow: 5px 0px 10px -5px $gray-1;
|
||||
$side-menu-link-color: $gray-6;
|
||||
|
@ -44,6 +44,11 @@
|
||||
border-right: 2px solid $gray-1;
|
||||
border-bottom: 2px solid $gray-1;
|
||||
}
|
||||
// temp fix since we use old commit of grid component
|
||||
// this can be removed when we revert to non fork grid component
|
||||
.react-grid-item > .react-resizable-handle {
|
||||
background-image: url('../img/resize-handle-white.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
|
@ -71,7 +71,7 @@
|
||||
// important to overlap it otherwise it can be hidden
|
||||
// again by the mouse getting outside the hover space
|
||||
left: $side-menu-width - 2px;
|
||||
@include animation("dropdown-anim 150ms ease-in-out 100ms forwards");
|
||||
@include animation('dropdown-anim 150ms ease-in-out 100ms forwards');
|
||||
z-index: $zindex-sidemenu;
|
||||
}
|
||||
}
|
||||
@ -193,9 +193,13 @@ li.sidemenu-org-switcher {
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
.sidemenu-open--xs {
|
||||
li {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
|
||||
.sidemenu {
|
||||
width: 100%;
|
||||
background: $side-menu-bg;
|
||||
background: $side-menu-bg-mobile;
|
||||
position: initial;
|
||||
height: auto;
|
||||
box-shadow: $side-menu-shadow;
|
||||
@ -214,6 +218,9 @@ li.sidemenu-org-switcher {
|
||||
.sidemenu__bottom {
|
||||
display: block;
|
||||
}
|
||||
.sidemenu-item {
|
||||
border-right: 2px solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sidemenu {
|
||||
|
@ -21,7 +21,7 @@ RUN gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A170311380
|
||||
RUN curl --silent --location https://rpm.nodesource.com/setup_6.x | bash - && \
|
||||
yum install -y nodejs --nogpgcheck
|
||||
|
||||
ENV GOLANG_VERSION 1.9.3
|
||||
ENV GOLANG_VERSION 1.10
|
||||
|
||||
RUN wget https://dl.yarnpkg.com/rpm/yarn.repo -O /etc/yum.repos.d/yarn.repo && \
|
||||
yum install -y yarn --nogpgcheck && \
|
||||
|
Loading…
Reference in New Issue
Block a user