Compare commits

...

30 Commits

Author SHA1 Message Date
Carl Bergquist
f14ad7445f Merge pull request #11248 from bergquist/cp_5.0.2
Cherry-picks for 5.0.2
2018-03-14 16:05:24 +01:00
bergquist
946a6c7d59 renderer: avoid redirect render requests
closes #11180

(cherry picked from commit 6cac7c2de9)
2018-03-14 15:42:34 +01:00
Daniel Lee
990168c2af teams: removes quota on route
Got added by mistake a year ago.

(cherry picked from commit 3f2c086e6f)
2018-03-14 15:06:58 +01:00
bergquist
95ce4725fb release 5.0.2 2018-03-14 14:48:39 +01:00
Marcus Efraimsson
7133cc1013 dashboard: fix import dashboard with alert rule
Importing a dashboard with alert rule(s) should be possible without receiving invalid
alert data error. This fix reverts the import logic to how it worked before Grafana v5.0,
that is import will allow dashboard with alert rule(s) but no alerts will be created.
After an import the user will need to update the dashboard for the alerts to be created.
Fixes #11227

(cherry picked from commit 87284d284e)
2018-03-14 14:47:37 +01:00
bergquist
3600f0ec0b session: recover from panics in session middleware
backport of 5f511deefcc247677e963e37986b076646a2f080i since 5.0.x
diverged from master.

ref #11155
2018-03-14 14:46:03 +01:00
bergquist
80c717cf6d alerting: supports extracting alerts from collapsed panels
collapsed rows wrap the hidden rows within itself. This caused
the extractor to miss the panel and therefore delete the alert
accosiated with the graph.

closes #11222

(cherry picked from commit be7ec310b1)
2018-03-14 14:40:32 +01:00
Marcus Efraimsson
9d58257be6 folders: should be possible to browse folder using only uid
That is, the slug part of url should be optional.

(cherry picked from commit 91fa076fb3)
2018-03-14 14:39:50 +01:00
Marcus Efraimsson
e1b554c61e dashboards: should be possible to browse dashboard using only uid
That is, the slug part of url should be optional.
Closes #11231

(cherry picked from commit 5fbfd67b94)
2018-03-14 14:39:41 +01:00
bergquist
a747ec349e release 5.0.1 2018-03-08 09:54:26 +01:00
Carl Bergquist
d9f3c0931a Merge pull request #11148 from bergquist/v5.0.x
Cherry pick #11145 and #11127
2018-03-07 17:30:10 +01:00
Dan Cech
f736e7aeb2 only use jwt token if it contains an email address
(cherry picked from commit 9d005c50a2)
2018-03-07 17:07:19 +01:00
Daniel Lee
84d6e67e6b alerting: fixes validation error when saving alerts in dash
If a panelId in the dashboard json is set to zero then the validation
silently fails. Instead of returning an error, it just ignores alerts and
saves the dashboard.

(cherry picked from commit d96fbb486f)
2018-03-07 17:06:50 +01:00
Daniel Lee
5ad76a29a0 Merge pull request #11142 from daniellee/v5.0.x
Cherrypick for V5.0.x
2018-03-07 15:51:41 +01:00
bergquist
5c6fd4f202 hide row actions for viewers
closes #11112

(cherry picked from commit d3b23b01d8)
2018-03-07 14:26:17 +01:00
Patrick O'Carroll
f13ffa3a52 made drop-menu into link
(cherry picked from commit 42cd462cbf)
2018-03-07 14:21:17 +01:00
Patrick O'Carroll
55809ee92a changed background for mobile menu background on light theme, increased font size in and added border-right in menu
(cherry picked from commit 9b3863a150)
2018-03-07 14:20:25 +01:00
Daniel Lee
a1db5561e0 scrolling: faster wheelspeed
ref #11053

(cherry picked from commit 8e81dc1e79)
2018-03-07 14:18:49 +01:00
Leonard Gram
3591e573f1 Merge pull request #11125 from xlson/cp_11121
Cherry pick #11121
2018-03-06 15:26:14 +01:00
Carl Bergquist
b29c2da7ef Merge pull request #11124 from bergquist/cp_11093
Cherry pick #11093
2018-03-06 15:23:03 +01:00
Leonard Gram
0f601dc4b1 alerting: Limits telegram captions to 200 chars.
The caption for inline images in Telegram is
limited to 200 characters.

Fixes #10975

(cherry picked from commit 891462b5d9)
2018-03-06 14:58:11 +01:00
Carl Bergquist
5742c4d603 Merge pull request #11117 from grafana/cp-fix-11103
cherry pick #11103 to v5.0.x
2018-03-06 14:55:38 +01:00
Carl Bergquist
2226506a1d Merge pull request #11120 from bergquist/cp_11097
Cherry-pick #11097
2018-03-06 14:44:13 +01:00
Sven Klemm
dcafc29bf2 use net/url to generate postgres connection url
(cherry picked from commit 4904a051cf)
2018-03-06 14:39:46 +01:00
bergquist
1b16a1ae81 fixes invalid link to profile pic when gravatar is disabled
closes #11097

(cherry picked from commit 5934521137)
2018-03-06 12:38:36 +01:00
Carl Bergquist
45e22da955 Fix Prometheus 2.0 stats (#11048) (#11115)
Fixes #11016

Signed-off-by: Julien Pivotto <roidelapluie@inuits.eu>
(cherry picked from commit 69c93e6401)
2018-03-06 11:27:56 +01:00
Torkel Ödegaard
bc660ff3d5 fix: restores white resize handle for panels, fixes #11103
(cherry picked from commit 0de90accfb)
2018-03-06 11:21:58 +01:00
Carl Bergquist
426ae8bdd3 Merge pull request #11113 from bergquist/cp-11109
Cherry-pick #11109
2018-03-06 11:05:26 +01:00
bergquist
5bab016837 ignore iteration property when checking for unsaved changes
closes #11063

(cherry picked from commit a7d62f44d3)
2018-03-06 10:41:37 +01:00
Daniel Lee
a7fcadd7c9 build: update publish_both with v5.0.0 2018-03-01 08:59:40 +01:00
30 changed files with 1114 additions and 178 deletions

View File

@@ -4,7 +4,7 @@
"company": "Grafana Labs"
},
"name": "grafana",
"version": "5.0.0",
"version": "5.0.2",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"

View File

@@ -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

View File

@@ -66,6 +66,7 @@ func (hs *HttpServer) registerRoutes() {
r.Get("/plugins/:id/page/:page", reqSignedIn, Index)
r.Get("/d/:uid/:slug", reqSignedIn, Index)
r.Get("/d/:uid", reqSignedIn, Index)
r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardUrl, Index)
r.Get("/dashboard/script/*", reqSignedIn, Index)
r.Get("/dashboard-solo/snapshot/*", Index)
@@ -150,11 +151,11 @@ func (hs *HttpServer) registerRoutes() {
apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
teamsRoute.Get("/:teamId", wrap(GetTeamById))
teamsRoute.Get("/search", wrap(SearchTeams))
teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam))
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), wrap(CreateTeam))
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
teamsRoute.Delete("/:teamId", wrap(DeleteTeamById))
teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers))
teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
}, reqOrgAdmin)

View File

@@ -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 {

View File

@@ -72,7 +72,9 @@ func RenderToPng(params *RenderOpts) (string, error) {
localDomain = setting.HttpAddr
}
url := fmt.Sprintf("%s://%s:%s/%s", setting.Protocol, localDomain, setting.HttpPort, params.Path)
// &render=1 signals to the legacy redirect layer to
// avoid redirect these requests.
url := fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, localDomain, setting.HttpPort, params.Path)
binPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, executable))
scriptPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, "render.js"))

View File

@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"gopkg.in/macaron.v1"
)
@@ -36,9 +37,14 @@ func RedirectFromLegacyDashboardUrl() macaron.Handler {
func RedirectFromLegacyDashboardSoloUrl() macaron.Handler {
return func(c *Context) {
slug := c.Params("slug")
renderRequest := c.QueryBool("render")
if slug != "" {
if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
if renderRequest && strings.Contains(url, setting.AppSubUrl) {
url = strings.Replace(url, setting.AppSubUrl, "", 1)
}
url = strings.Replace(url, "/d/", "/d-solo/", 1)
url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery)
c.Redirect(url, 301)

View File

@@ -113,6 +113,18 @@ type SessionWrapper struct {
}
func (s *SessionWrapper) Start(c *Context) error {
// See https://github.com/grafana/grafana/issues/11155 for details on why
// a recover and retry is needed
defer func() error {
if err := recover(); err != nil {
var retryErr error
s.session, retryErr = s.manager.Start(c.Context)
return retryErr
}
return nil
}()
var err error
s.session, err = s.manager.Start(c.Context)
return err

View File

@@ -80,7 +80,7 @@ func ImportDashboard(cmd *ImportDashboardCommand) error {
User: cmd.User,
}
savedDash, err := dashboards.NewService().SaveDashboard(dto)
savedDash, err := dashboards.NewService().ImportDashboard(dto)
if err != nil {
return err

View File

@@ -74,6 +74,21 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json)
for _, panelObj := range jsonWithPanels.Get("panels").MustArray() {
panel := simplejson.NewFromAny(panelObj)
collapsedJson, collapsed := panel.CheckGet("collapsed")
// check if the panel is collapsed
if collapsed && collapsedJson.MustBool() {
// extract alerts from sub panels for collapsed panels
als, err := e.GetAlertFromPanels(panel)
if err != nil {
return nil, err
}
alerts = append(alerts, als...)
continue
}
jsonAlert, hasAlert := panel.CheckGet("alert")
if !hasAlert {
@@ -143,10 +158,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
}
}

View File

@@ -22,6 +22,7 @@ func TestAlertRuleExtraction(t *testing.T) {
defaultDs := &m.DataSource{Id: 12, OrgId: 1, Name: "I am default", IsDefault: true}
graphite2Ds := &m.DataSource{Id: 15, OrgId: 1, Name: "graphite2"}
influxDBDs := &m.DataSource{Id: 16, OrgId: 1, Name: "InfluxDB"}
prom := &m.DataSource{Id: 17, OrgId: 1, Name: "Prometheus"}
bus.AddHandler("test", func(query *m.GetDataSourcesQuery) error {
query.Result = []*m.DataSource{defaultDs, graphite2Ds}
@@ -38,6 +39,10 @@ func TestAlertRuleExtraction(t *testing.T) {
if query.Name == influxDBDs.Name {
query.Result = influxDBDs
}
if query.Name == prom.Name {
query.Result = prom
}
return nil
})
@@ -150,6 +155,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)
@@ -198,5 +219,26 @@ func TestAlertRuleExtraction(t *testing.T) {
}
})
})
Convey("Should be able to extract collapsed panels", func() {
json, err := ioutil.ReadFile("./test-data/collapsed-panels.json")
So(err, ShouldBeNil)
dashJson, err := simplejson.NewJson(json)
So(err, ShouldBeNil)
dash := m.NewDashboardFromJson(dashJson)
extractor := NewDashAlertExtractor(dash, 1)
alerts, err := extractor.GetAlerts()
Convey("Get rules without error", func() {
So(err, ShouldBeNil)
})
Convey("should be able to extract collapsed alerts", func() {
So(len(alerts), ShouldEqual, 4)
})
})
})
}

View File

@@ -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)
}

View File

@@ -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")
})
})
})
})
}

View File

@@ -0,0 +1,597 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"id": 127,
"links": [],
"panels": [
{
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 9,
"title": "Row title",
"type": "row"
},
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
200
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"A",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"executionErrorState": "alerting",
"frequency": "10s",
"handler": 1,
"name": "Panel Title alert",
"noDataState": "no_data",
"notifications": []
},
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 1
},
"id": 10,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "{{job}}",
"refId": "A"
}
],
"thresholds": [
{
"colorMode": "critical",
"fill": true,
"line": true,
"op": "gt",
"value": 200
}
],
"timeFrom": null,
"timeShift": null,
"title": "Panel Title",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
]
},
{
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 1
},
"id": 14,
"limit": 10,
"links": [],
"onlyAlertsOnDashboard": true,
"show": "current",
"sortOrder": 1,
"stateFilter": [],
"title": "Panel Title",
"type": "alertlist"
},
{
"collapsed": true,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 10
},
"id": 6,
"panels": [
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
200
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"A",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"executionErrorState": "alerting",
"frequency": "10s",
"handler": 1,
"name": "Panel 2 alert",
"noDataState": "no_data",
"notifications": []
},
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 11
},
"id": 11,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "{{job}}",
"refId": "A"
}
],
"thresholds": [
{
"colorMode": "critical",
"fill": true,
"line": true,
"op": "gt",
"value": 200
}
],
"timeFrom": null,
"timeShift": null,
"title": "Panel 2",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
]
},
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
200
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"A",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"executionErrorState": "alerting",
"frequency": "10s",
"handler": 1,
"name": "Panel 4 alert",
"noDataState": "no_data",
"notifications": []
},
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 11
},
"id": 15,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "{{job}}",
"refId": "A"
}
],
"thresholds": [
{
"colorMode": "critical",
"fill": true,
"line": true,
"op": "gt",
"value": 200
}
],
"timeFrom": null,
"timeShift": null,
"title": "Panel 4",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
]
}
],
"title": "Row title",
"type": "row"
},
{
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 11
},
"id": 4,
"title": "Row title",
"type": "row"
},
{
"alert": {
"conditions": [
{
"evaluator": {
"params": [
200
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"A",
"5m",
"now"
]
},
"reducer": {
"params": [],
"type": "avg"
},
"type": "query"
}
],
"executionErrorState": "alerting",
"frequency": "10s",
"handler": 1,
"name": "Panel 3 alert",
"noDataState": "no_data",
"notifications": []
},
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "Prometheus",
"fill": 1,
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 12
},
"id": 12,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": true,
"total": false,
"values": false
},
"lines": true,
"linewidth": 1,
"links": [],
"nullPointMode": "null",
"percentage": false,
"pointradius": 5,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "go_goroutines",
"format": "time_series",
"intervalFactor": 1,
"legendFormat": "{{job}}",
"refId": "A"
}
],
"thresholds": [
{
"colorMode": "critical",
"fill": true,
"line": true,
"op": "gt",
"value": 200
}
],
"timeFrom": null,
"timeShift": null,
"title": "Panel 3",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
]
}
],
"schemaVersion": 16,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
]
},
"timezone": "",
"title": "New dashboard Copy",
"uid": "6v5pg36zk",
"version": 17
}

View 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]}
}
]
}
}
]
}
]
}

View File

@@ -13,6 +13,7 @@ import (
// DashboardService service for operating on dashboards
type DashboardService interface {
SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error)
ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error)
}
// DashboardProvisioningService service for operating on provisioned dashboards
@@ -214,6 +215,20 @@ func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Da
return cmd.Result, nil
}
func (dr *dashboardServiceImpl) ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
cmd, err := dr.buildSaveDashboardCommand(dto, false)
if err != nil {
return nil, err
}
err = bus.Dispatch(cmd)
if err != nil {
return nil, err
}
return cmd.Result, nil
}
type FakeDashboardService struct {
SaveDashboardResult *models.Dashboard
SaveDashboardError error
@@ -230,6 +245,10 @@ func (s *FakeDashboardService) SaveDashboard(dto *SaveDashboardDTO) (*models.Das
return s.SaveDashboardResult, s.SaveDashboardError
}
func (s *FakeDashboardService) ImportDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
return s.SaveDashboard(dto)
}
func MockDashboardService(mock *FakeDashboardService) {
NewService = func() DashboardService {
return mock

View File

@@ -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 ""
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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>&nbsp;Close</span>
<span class="sidemenu__close">
<i class="fa fa-times"></i>&nbsp;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>

View File

@@ -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>
);

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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,

View File

@@ -22,6 +22,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
reloadOnSearch: false,
pageClass: 'page-dashboard',
})
.when('/d/:uid', {
templateUrl: 'public/app/partials/dashboard.html',
controller: 'LoadDashboardCtrl',
reloadOnSearch: false,
pageClass: 'page-dashboard',
})
.when('/dashboard/:type/:slug', {
templateUrl: 'public/app/partials/dashboard.html',
controller: 'LoadDashboardCtrl',
@@ -98,6 +104,11 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
controller: 'FolderDashboardsCtrl',
controllerAs: 'ctrl',
})
.when('/dashboards/f/:uid', {
templateUrl: 'public/app/features/dashboard/partials/folder_dashboards.html',
controller: 'FolderDashboardsCtrl',
controllerAs: 'ctrl',
})
.when('/org', {
templateUrl: 'public/app/features/org/partials/orgDetails.html',
controller: 'OrgDetailsCtrl',

View 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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {