mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 19:44:55 +08:00
Compare commits
21 Commits
docs/updat
...
v5.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a747ec349e | ||
|
|
d9f3c0931a | ||
|
|
f736e7aeb2 | ||
|
|
84d6e67e6b | ||
|
|
5ad76a29a0 | ||
|
|
5c6fd4f202 | ||
|
|
f13ffa3a52 | ||
|
|
55809ee92a | ||
|
|
a1db5561e0 | ||
|
|
3591e573f1 | ||
|
|
b29c2da7ef | ||
|
|
0f601dc4b1 | ||
|
|
5742c4d603 | ||
|
|
2226506a1d | ||
|
|
dcafc29bf2 | ||
|
|
1b16a1ae81 | ||
|
|
45e22da955 | ||
|
|
bc660ff3d5 | ||
|
|
426ae8bdd3 | ||
|
|
5bab016837 | ||
|
|
a7fcadd7c9 |
@@ -4,7 +4,7 @@
|
||||
"company": "Grafana Labs"
|
||||
},
|
||||
"name": "grafana",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://github.com/grafana/grafana.git"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#! /usr/bin/env bash
|
||||
version=4.6.3
|
||||
version=5.0.0
|
||||
|
||||
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ func generateConnectionString(datasource *models.DataSource) string {
|
||||
}
|
||||
|
||||
sslmode := datasource.JsonData.Get("sslmode").MustString("verify-full")
|
||||
return fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", url.PathEscape(datasource.User), url.PathEscape(password), url.PathEscape(datasource.Url), url.PathEscape(datasource.Database), url.QueryEscape(sslmode))
|
||||
u := &url.URL{Scheme: "postgres", User: url.UserPassword(datasource.User, password), Host: datasource.Url, Path: datasource.Database, RawQuery: "sslmode=" + sslmode}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (e *PostgresQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,11 @@ describe('unsavedChangesSrv', function() {
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
});
|
||||
|
||||
it('Should ignore .iteration changes', () => {
|
||||
dash.iteration = new Date().getTime() + 1;
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
});
|
||||
|
||||
it.skip('Should ignore row collapse change', function() {
|
||||
dash.rows[0].collapse = true;
|
||||
expect(tracker.hasChanges()).to.be(false);
|
||||
|
||||
@@ -97,6 +97,9 @@ export class Tracker {
|
||||
dash.refresh = 0;
|
||||
dash.schemaVersion = 0;
|
||||
|
||||
// ignore iteration property
|
||||
delete dash.iteration;
|
||||
|
||||
// filter row and panels properties that should be ignored
|
||||
dash.rows = _.filter(dash.rows, function(row) {
|
||||
if (row.repeatRowId) {
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
"tableColumn": "",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "tsdb_wal_corruptions_total{job=\"prometheus\"}",
|
||||
"expr": "prometheus_tsdb_wal_corruptions_total{job=\"prometheus\"}",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 2,
|
||||
"legendFormat": "",
|
||||
@@ -1048,7 +1048,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "max(prometheus_evaluator_duration_seconds{job=\"prometheus\", quantile!=\"0.01\", quantile!=\"0.05\"}) by (quantile)",
|
||||
"expr": "max(prometheus_rule_group_duration_seconds{job=\"prometheus\"}) by (quantile)",
|
||||
"format": "time_series",
|
||||
"interval": "",
|
||||
"intervalFactor": 2,
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Rule Eval Duration",
|
||||
"title": "Rule Group Eval Duration",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
@@ -1124,7 +1124,7 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(prometheus_evaluator_iterations_missed_total{job=\"prometheus\"}[5m])",
|
||||
"expr": "rate(prometheus_rule_group_iterations_missed_total{job=\"prometheus\"}[5m])",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 2,
|
||||
"legendFormat": "missed",
|
||||
@@ -1132,15 +1132,7 @@
|
||||
"step": 10
|
||||
},
|
||||
{
|
||||
"expr": "rate(prometheus_evaluator_iterations_skipped_total{job=\"prometheus\"}[5m])",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 2,
|
||||
"legendFormat": "skipped",
|
||||
"refId": "C",
|
||||
"step": 10
|
||||
},
|
||||
{
|
||||
"expr": "rate(prometheus_evaluator_iterations_total{job=\"prometheus\"}[5m])",
|
||||
"expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[5m])",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 2,
|
||||
"legendFormat": "iterations",
|
||||
@@ -1151,7 +1143,7 @@
|
||||
"thresholds": [],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Rule Eval Activity",
|
||||
"title": "Rule Group Eval Activity",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user