diff --git a/docs/sources/reference/http_api.md b/docs/sources/reference/http_api.md index 4b0dff6c4b6..70431565741 100644 --- a/docs/sources/reference/http_api.md +++ b/docs/sources/reference/http_api.md @@ -75,7 +75,7 @@ Creates a new dashboard or updates an existing dashboard. JSON Body schema: -- **dashboard** – The complete dashboard model, id = null to create a new dashboard +- **dashboard** – The complete dashboard model, id = null to create a new dashboard. - **overwrite** – Set to true if you want to overwrite existing dashboard with newer version or with same dashboard title. **Example Response**: diff --git a/pkg/api/api.go b/pkg/api/api.go index 99ab8a51ff2..6e7015ed889 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -69,9 +69,11 @@ func Register(r *macaron.Macaron) { r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), wrap(ResetPassword)) // dashboard snapshots - r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) r.Get("/dashboard/snapshot/*", Index) + r.Get("/dashboard/snapshots/", reqSignedIn, Index) + // api for dashboard snapshots + r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) r.Get("/api/snapshot/shared-options/", GetSharingOptions) r.Get("/api/snapshots/:key", GetDashboardSnapshot) r.Get("/api/snapshots-delete/:key", DeleteDashboardSnapshot) @@ -183,6 +185,11 @@ func Register(r *macaron.Macaron) { r.Get("/tags", GetDashboardTags) }) + // Dashboard snapshots + r.Group("/dashboard/snapshots", func() { + r.Get("/", wrap(SearchDashboardSnapshots)) + }) + // Playlist r.Group("/playlists", func() { r.Get("/", wrap(SearchPlaylists)) diff --git a/pkg/api/cloudwatch/metrics.go b/pkg/api/cloudwatch/metrics.go index 7f8b30bf312..7a98386347c 100644 --- a/pkg/api/cloudwatch/metrics.go +++ b/pkg/api/cloudwatch/metrics.go @@ -3,7 +3,14 @@ package cloudwatch import ( "encoding/json" "sort" + "strings" + "sync" + "time" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awsutil" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/util" ) @@ -11,6 +18,14 @@ import ( var metricsMap map[string][]string var dimensionsMap map[string][]string +type CustomMetricsCache struct { + Expire time.Time + Cache []string +} + +var customMetricsMetricsMap map[string]map[string]map[string]*CustomMetricsCache +var customMetricsDimensionsMap map[string]map[string]map[string]*CustomMetricsCache + func init() { metricsMap = map[string][]string{ "AWS/AutoScaling": {"GroupMinSize", "GroupMaxSize", "GroupDesiredCapacity", "GroupInServiceInstances", "GroupPendingInstances", "GroupStandbyInstances", "GroupTerminatingInstances", "GroupTotalInstances"}, @@ -85,6 +100,9 @@ func init() { "AWS/WAF": {"Rule", "WebACL"}, "AWS/WorkSpaces": {"DirectoryId", "WorkspaceId"}, } + + customMetricsMetricsMap = make(map[string]map[string]map[string]*CustomMetricsCache) + customMetricsDimensionsMap = make(map[string]map[string]map[string]*CustomMetricsCache) } // Whenever this list is updated, frontend list should also be updated. @@ -127,10 +145,19 @@ func handleGetMetrics(req *cwRequest, c *middleware.Context) { json.Unmarshal(req.Body, reqParam) - namespaceMetrics, exists := metricsMap[reqParam.Parameters.Namespace] - if !exists { - c.JsonApiErr(404, "Unable to find namespace "+reqParam.Parameters.Namespace, nil) - return + var namespaceMetrics []string + if !isCustomMetrics(reqParam.Parameters.Namespace) { + var exists bool + if namespaceMetrics, exists = metricsMap[reqParam.Parameters.Namespace]; !exists { + c.JsonApiErr(404, "Unable to find namespace "+reqParam.Parameters.Namespace, nil) + return + } + } else { + var err error + if namespaceMetrics, err = getMetricsForCustomMetrics(req.Region, reqParam.Parameters.Namespace, req.DataSource.Database, getAllMetrics); err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } } sort.Sort(sort.StringSlice(namespaceMetrics)) @@ -151,10 +178,19 @@ func handleGetDimensions(req *cwRequest, c *middleware.Context) { json.Unmarshal(req.Body, reqParam) - dimensionValues, exists := dimensionsMap[reqParam.Parameters.Namespace] - if !exists { - c.JsonApiErr(404, "Unable to find dimension "+reqParam.Parameters.Namespace, nil) - return + var dimensionValues []string + if !isCustomMetrics(reqParam.Parameters.Namespace) { + var exists bool + if dimensionValues, exists = dimensionsMap[reqParam.Parameters.Namespace]; !exists { + c.JsonApiErr(404, "Unable to find dimension "+reqParam.Parameters.Namespace, nil) + return + } + } else { + var err error + if dimensionValues, err = getDimensionsForCustomMetrics(req.Region, reqParam.Parameters.Namespace, req.DataSource.Database, getAllMetrics); err != nil { + c.JsonApiErr(500, "Unable to call AWS API", err) + return + } } sort.Sort(sort.StringSlice(dimensionValues)) @@ -165,3 +201,122 @@ func handleGetDimensions(req *cwRequest, c *middleware.Context) { c.JSON(200, result) } + +func getAllMetrics(region string, namespace string, database string) (cloudwatch.ListMetricsOutput, error) { + cfg := &aws.Config{ + Region: aws.String(region), + Credentials: getCredentials(database), + } + + svc := cloudwatch.New(session.New(cfg), cfg) + + params := &cloudwatch.ListMetricsInput{ + Namespace: aws.String(namespace), + } + + var resp cloudwatch.ListMetricsOutput + err := svc.ListMetricsPages(params, + func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool { + metrics, _ := awsutil.ValuesAtPath(page, "Metrics") + for _, metric := range metrics { + resp.Metrics = append(resp.Metrics, metric.(*cloudwatch.Metric)) + } + return !lastPage + }) + if err != nil { + return resp, err + } + + return resp, nil +} + +var metricsCacheLock sync.Mutex + +func getMetricsForCustomMetrics(region string, namespace string, database string, getAllMetrics func(string, string, string) (cloudwatch.ListMetricsOutput, error)) ([]string, error) { + result, err := getAllMetrics(region, namespace, database) + if err != nil { + return []string{}, err + } + + metricsCacheLock.Lock() + defer metricsCacheLock.Unlock() + + if _, ok := customMetricsMetricsMap[database]; !ok { + customMetricsMetricsMap[database] = make(map[string]map[string]*CustomMetricsCache) + } + if _, ok := customMetricsMetricsMap[database][region]; !ok { + customMetricsMetricsMap[database][region] = make(map[string]*CustomMetricsCache) + } + if _, ok := customMetricsMetricsMap[database][region][namespace]; !ok { + customMetricsMetricsMap[database][region][namespace] = &CustomMetricsCache{} + customMetricsMetricsMap[database][region][namespace].Cache = make([]string, 0) + } + + if customMetricsMetricsMap[database][region][namespace].Expire.After(time.Now()) { + return customMetricsMetricsMap[database][region][namespace].Cache, nil + } + customMetricsMetricsMap[database][region][namespace].Cache = make([]string, 0) + customMetricsMetricsMap[database][region][namespace].Expire = time.Now().Add(5 * time.Minute) + + for _, metric := range result.Metrics { + if isDuplicate(customMetricsMetricsMap[database][region][namespace].Cache, *metric.MetricName) { + continue + } + customMetricsMetricsMap[database][region][namespace].Cache = append(customMetricsMetricsMap[database][region][namespace].Cache, *metric.MetricName) + } + + return customMetricsMetricsMap[database][region][namespace].Cache, nil +} + +var dimensionsCacheLock sync.Mutex + +func getDimensionsForCustomMetrics(region string, namespace string, database string, getAllMetrics func(string, string, string) (cloudwatch.ListMetricsOutput, error)) ([]string, error) { + result, err := getAllMetrics(region, namespace, database) + if err != nil { + return []string{}, err + } + + dimensionsCacheLock.Lock() + defer dimensionsCacheLock.Unlock() + + if _, ok := customMetricsDimensionsMap[database]; !ok { + customMetricsDimensionsMap[database] = make(map[string]map[string]*CustomMetricsCache) + } + if _, ok := customMetricsDimensionsMap[database][region]; !ok { + customMetricsDimensionsMap[database][region] = make(map[string]*CustomMetricsCache) + } + if _, ok := customMetricsDimensionsMap[database][region][namespace]; !ok { + customMetricsDimensionsMap[database][region][namespace] = &CustomMetricsCache{} + customMetricsDimensionsMap[database][region][namespace].Cache = make([]string, 0) + } + + if customMetricsDimensionsMap[database][region][namespace].Expire.After(time.Now()) { + return customMetricsDimensionsMap[database][region][namespace].Cache, nil + } + customMetricsDimensionsMap[database][region][namespace].Cache = make([]string, 0) + customMetricsDimensionsMap[database][region][namespace].Expire = time.Now().Add(5 * time.Minute) + + for _, metric := range result.Metrics { + for _, dimension := range metric.Dimensions { + if isDuplicate(customMetricsDimensionsMap[database][region][namespace].Cache, *dimension.Name) { + continue + } + customMetricsDimensionsMap[database][region][namespace].Cache = append(customMetricsDimensionsMap[database][region][namespace].Cache, *dimension.Name) + } + } + + return customMetricsDimensionsMap[database][region][namespace].Cache, nil +} + +func isDuplicate(nameList []string, target string) bool { + for _, name := range nameList { + if name == target { + return true + } + } + return false +} + +func isCustomMetrics(namespace string) bool { + return strings.Index(namespace, "AWS/") != 0 +} diff --git a/pkg/api/cloudwatch/metrics_test.go b/pkg/api/cloudwatch/metrics_test.go new file mode 100644 index 00000000000..ec39452e116 --- /dev/null +++ b/pkg/api/cloudwatch/metrics_test.go @@ -0,0 +1,63 @@ +package cloudwatch + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudwatch" + . "github.com/smartystreets/goconvey/convey" +) + +func TestCloudWatchMetrics(t *testing.T) { + + Convey("When calling getMetricsForCustomMetrics", t, func() { + region := "us-east-1" + namespace := "Foo" + database := "default" + f := func(region string, namespace string, database string) (cloudwatch.ListMetricsOutput, error) { + return cloudwatch.ListMetricsOutput{ + Metrics: []*cloudwatch.Metric{ + { + MetricName: aws.String("Test_MetricName"), + Dimensions: []*cloudwatch.Dimension{ + { + Name: aws.String("Test_DimensionName"), + }, + }, + }, + }, + }, nil + } + metrics, _ := getMetricsForCustomMetrics(region, namespace, database, f) + + Convey("Should contain Test_MetricName", func() { + So(metrics, ShouldContain, "Test_MetricName") + }) + }) + + Convey("When calling getDimensionsForCustomMetrics", t, func() { + region := "us-east-1" + namespace := "Foo" + database := "default" + f := func(region string, namespace string, database string) (cloudwatch.ListMetricsOutput, error) { + return cloudwatch.ListMetricsOutput{ + Metrics: []*cloudwatch.Metric{ + { + MetricName: aws.String("Test_MetricName"), + Dimensions: []*cloudwatch.Dimension{ + { + Name: aws.String("Test_DimensionName"), + }, + }, + }, + }, + }, nil + } + dimensionKeys, _ := getDimensionsForCustomMetrics(region, namespace, database, f) + + Convey("Should contain Test_DimensionName", func() { + So(dimensionKeys, ShouldContain, "Test_DimensionName") + }) + }) + +} diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 79485fa8cca..c0c7cd0e4f5 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -49,17 +49,13 @@ func GetDashboard(c *middleware.Context) { dash := query.Result - // Finding the last updater of the dashboard - updater := "Anonymous" - if dash.UpdatedBy != 0 { - userQuery := m.GetUserByIdQuery{Id: dash.UpdatedBy} - userErr := bus.Dispatch(&userQuery) - if userErr != nil { - updater = "Unknown" - } else { - user := userQuery.Result - updater = user.Login - } + // Finding creator and last updater of the dashboard + updater, creator := "Anonymous", "Anonymous" + if dash.UpdatedBy > 0 { + updater = getUserLogin(dash.UpdatedBy) + } + if dash.CreatedBy > 0 { + creator = getUserLogin(dash.CreatedBy) } dto := dtos.DashboardFullWithMeta{ @@ -74,12 +70,25 @@ func GetDashboard(c *middleware.Context) { Created: dash.Created, Updated: dash.Updated, UpdatedBy: updater, + CreatedBy: creator, + Version: dash.Version, }, } c.JSON(200, dto) } +func getUserLogin(userId int64) string { + query := m.GetUserByIdQuery{Id: userId} + err := bus.Dispatch(&query) + if err != nil { + return "Anonymous" + } else { + user := query.Result + return user.Login + } +} + func DeleteDashboard(c *middleware.Context) { slug := c.Params(":slug") @@ -104,9 +113,9 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) { cmd.OrgId = c.OrgId if !c.IsSignedIn { - cmd.UpdatedBy = 0 + cmd.UserId = -1 } else { - cmd.UpdatedBy = c.UserId + cmd.UserId = c.UserId } dash := cmd.GetDashboardModel() diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index f2aee67205c..8ed848a7b11 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -36,7 +36,6 @@ func CreateDashboardSnapshot(c *middleware.Context, cmd m.CreateDashboardSnapsho cmd.DeleteKey = util.GetRandomString(32) cmd.OrgId = c.OrgId cmd.UserId = c.UserId - cmd.Name = c.Name metrics.M_Api_Dashboard_Snapshot_Create.Inc(1) } @@ -99,3 +98,43 @@ func DeleteDashboardSnapshot(c *middleware.Context) { c.JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from a CDN cache."}) } + +func SearchDashboardSnapshots(c *middleware.Context) Response { + query := c.Query("query") + limit := c.QueryInt("limit") + + if limit == 0 { + limit = 1000 + } + + searchQuery := m.GetDashboardSnapshotsQuery{ + Name: query, + Limit: limit, + OrgId: c.OrgId, + } + + err := bus.Dispatch(&searchQuery) + if err != nil { + return ApiError(500, "Search failed", err) + } + + dtos := make([]*m.DashboardSnapshotDTO, len(searchQuery.Result)) + for i, snapshot := range searchQuery.Result { + dtos[i] = &m.DashboardSnapshotDTO{ + Id: snapshot.Id, + Name: snapshot.Name, + Key: snapshot.Key, + DeleteKey: snapshot.DeleteKey, + OrgId: snapshot.OrgId, + UserId: snapshot.UserId, + External: snapshot.External, + ExternalUrl: snapshot.ExternalUrl, + Expires: snapshot.Expires, + Created: snapshot.Created, + Updated: snapshot.Updated, + } + } + + return Json(200, dtos) + //return Json(200, searchQuery.Result) +} diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index a5e8e74fb46..83176e836e5 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -42,6 +42,8 @@ type DashboardMeta struct { Created time.Time `json:"created"` Updated time.Time `json:"updated"` UpdatedBy string `json:"updatedBy"` + CreatedBy string `json:"createdBy"` + Version int `json:"version"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/index.go b/pkg/api/index.go index d3bdff12b09..09ef756f95a 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -60,6 +60,12 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) { Url: "/playlists", }) + data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ + Text: "Snapshots", + Icon: "fa fa-fw fa-camera-retro", + Url: "/dashboard/snapshots", + }) + if c.OrgRole == m.ROLE_ADMIN { data.MainNavLinks = append(data.MainNavLinks, &dtos.NavLink{ Text: "Data Sources", diff --git a/pkg/models/dashboard_snapshot.go b/pkg/models/dashboard_snapshot.go index cc563646833..9bfbd06c1ef 100644 --- a/pkg/models/dashboard_snapshot.go +++ b/pkg/models/dashboard_snapshot.go @@ -20,6 +20,22 @@ type DashboardSnapshot struct { Dashboard map[string]interface{} } +// DashboardSnapshotDTO without dashboard map +type DashboardSnapshotDTO struct { + Id int64 `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + DeleteKey string `json:"deleteKey"` + OrgId int64 `json:"orgId"` + UserId int64 `json:"userId"` + External bool `json:"external"` + ExternalUrl string `json:"externalUrl"` + + Expires time.Time `json:"expires"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + // ----------------- // COMMANDS @@ -48,3 +64,13 @@ type GetDashboardSnapshotQuery struct { Result *DashboardSnapshot } + +type DashboardSnapshots []*DashboardSnapshot + +type GetDashboardSnapshotsQuery struct { + Name string + Limit int + OrgId int64 + + Result DashboardSnapshots +} diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 63ed1f5c006..1015dfbe28c 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -34,6 +34,7 @@ type Dashboard struct { Updated time.Time UpdatedBy int64 + CreatedBy int64 Title string Data map[string]interface{} @@ -91,8 +92,11 @@ func NewDashboardFromJson(data map[string]interface{}) *Dashboard { // GetDashboardModel turns the command into the savable model func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { dash := NewDashboardFromJson(cmd.Dashboard) + if dash.Data["version"] == 0 { + dash.CreatedBy = cmd.UserId + } + dash.UpdatedBy = cmd.UserId dash.OrgId = cmd.OrgId - dash.UpdatedBy = cmd.UpdatedBy dash.UpdateSlug() return dash } @@ -114,9 +118,9 @@ func (dash *Dashboard) UpdateSlug() { type SaveDashboardCommand struct { Dashboard map[string]interface{} `json:"dashboard" binding:"Required"` - Overwrite bool `json:"overwrite"` + UserId int64 `json:"userId"` OrgId int64 `json:"-"` - UpdatedBy int64 `json:"-"` + Overwrite bool `json:"overwrite"` Result *Dashboard } diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index dc8862f2d57..fc94a91cce5 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -12,6 +12,7 @@ func init() { bus.AddHandler("sql", CreateDashboardSnapshot) bus.AddHandler("sql", GetDashboardSnapshot) bus.AddHandler("sql", DeleteDashboardSnapshot) + bus.AddHandler("sql", SearchDashboardSnapshots) } func CreateDashboardSnapshot(cmd *m.CreateDashboardSnapshotCommand) error { @@ -64,3 +65,18 @@ func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error { query.Result = &snapshot return nil } + +func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error { + var snapshots = make(m.DashboardSnapshots, 0) + + sess := x.Limit(query.Limit) + + if query.Name != "" { + sess.Where("name LIKE ?", query.Name) + } + + sess.Where("org_id = ?", query.OrgId) + err := sess.Find(&snapshots) + query.Result = snapshots + return err +} diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index b94f1a7d6ac..a4a8629b331 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -97,4 +97,9 @@ func addDashboardMigration(mg *Migrator) { mg.AddMigration("Add column updated_by in dashboard - v2", NewAddColumnMigration(dashboardV2, &Column{ Name: "updated_by", Type: DB_Int, Nullable: true, })) + + // add column to store creator of a dashboard + mg.AddMigration("Add column created_by in dashboard - v2", NewAddColumnMigration(dashboardV2, &Column{ + Name: "created_by", Type: DB_Int, Nullable: true, + })) } diff --git a/public/app/core/routes/all.js b/public/app/core/routes/all.js index ab3bef61ed7..6c6ec4f0724 100644 --- a/public/app/core/routes/all.js +++ b/public/app/core/routes/all.js @@ -137,6 +137,11 @@ define([ templateUrl: 'public/app/partials/reset_password.html', controller : 'ResetPasswordCtrl', }) + .when('/dashboard/snapshots', { + templateUrl: 'app/features/snapshot/partials/snapshots.html', + controller : 'SnapshotsCtrl', + controllerAs: 'ctrl', + }) .when('/apps', { templateUrl: 'public/app/features/apps/partials/list.html', controller: 'AppListCtrl', diff --git a/public/app/features/all.js b/public/app/features/all.js index d4436c9deaa..4ec4719ebdd 100644 --- a/public/app/features/all.js +++ b/public/app/features/all.js @@ -5,6 +5,7 @@ define([ './templating/templateSrv', './dashboard/all', './playlist/all', + './snapshot/all', './panel/all', './profile/profileCtrl', './profile/changePasswordCtrl', diff --git a/public/app/features/dashboard/partials/settings.html b/public/app/features/dashboard/partials/settings.html index f582252cd3c..7af2abfe076 100644 --- a/public/app/features/dashboard/partials/settings.html +++ b/public/app/features/dashboard/partials/settings.html @@ -115,9 +115,9 @@
| Name | +Snapshot url | ++ | + + + + |
|---|---|---|---|
| + {{snapshot.name}} + | ++ dashboard/snapshot/{{snapshot.key}} + | ++ + + View + + | ++ + + + | +