소스 검색

Merge branch 'v3.1.x'

Torkel Ödegaard 9 년 전
부모
커밋
4bb4d7f7d3
39개의 변경된 파일506개의 추가작업 그리고 208개의 파일을 삭제
  1. 1 1
      docs/sources/installation/configuration.md
  2. 3 10
      docs/sources/installation/debian.md
  3. 4 22
      docs/sources/installation/rpm.md
  4. 1 1
      docs/sources/installation/windows.md
  5. 2 2
      package.json
  6. 1 1
      pkg/api/api.go
  7. 17 14
      pkg/api/dashboard.go
  8. 1 1
      pkg/api/playlist_play.go
  9. 23 6
      pkg/models/dashboards.go
  10. 20 4
      pkg/models/plugin_settings.go
  11. 1 0
      pkg/plugins/dashboard_importer.go
  12. 46 35
      pkg/plugins/dashboards.go
  13. 17 5
      pkg/plugins/dashboards_test.go
  14. 139 0
      pkg/plugins/dashboards_updater.go
  15. 2 0
      pkg/plugins/plugins.go
  16. 21 2
      pkg/services/sqlstore/dashboard.go
  17. 9 0
      pkg/services/sqlstore/migrations/dashboard_mig.go
  18. 6 0
      pkg/services/sqlstore/migrations/plugin_setting.go
  19. 40 4
      pkg/services/sqlstore/plugin_setting.go
  20. 4 0
      public/app/core/components/search/search.html
  21. 2 0
      public/app/core/services/alert_srv.ts
  22. 1 0
      public/app/features/dashboard/dashboardSrv.js
  23. 19 0
      public/app/features/dashboard/dashnav/dashnav.ts
  24. 5 2
      public/app/features/dashboard/saveDashboardAsCtrl.js
  25. 7 8
      public/app/features/plugins/import_list/import_list.html
  26. 36 28
      public/app/features/plugins/partials/plugin_list.html
  27. 1 22
      public/app/features/plugins/plugin_edit_ctrl.ts
  28. 2 1
      public/app/partials/confirm_modal.html
  29. 6 1
      public/app/plugins/panel/table/editor.html
  30. 2 2
      public/app/plugins/panel/table/module.ts
  31. 11 6
      public/app/plugins/panel/table/renderer.ts
  32. 16 1
      public/app/plugins/panel/table/specs/renderer_specs.ts
  33. 0 0
      public/img/grafana_net_logo.svg
  34. 1 1
      public/sass/components/_modals.scss
  35. 16 0
      public/sass/components/_search.scss
  36. 20 25
      public/sass/pages/_plugins.scss
  37. 1 1
      tests/test-app/dashboards/connections.json
  38. 1 1
      tests/test-app/dashboards/connections_result.json
  39. 1 1
      tests/test-app/dashboards/memory.json

+ 1 - 1
docs/sources/installation/configuration.md

@@ -46,7 +46,7 @@ Then you can override them using:
 
 
 ## instance_name
 ## instance_name
 Set the name of the grafana-server instance. Used in logging and internal metrics and in
 Set the name of the grafana-server instance. Used in logging and internal metrics and in
-clustering info. Defaults to: `${HOSTNAME}, which will be replaced with
+clustering info. Defaults to: `${HOSTNAME}`, which will be replaced with
 environment variable `HOSTNAME`, if that is empty or does not exist Grafana will try to use
 environment variable `HOSTNAME`, if that is empty or does not exist Grafana will try to use
 system calls to get the machine name.
 system calls to get the machine name.
 
 

+ 3 - 10
docs/sources/installation/debian.md

@@ -10,20 +10,13 @@ page_keywords: grafana, installation, debian, ubuntu, guide
 
 
 Description | Download
 Description | Download
 ------------ | -------------
 ------------ | -------------
-Stable .deb for Debian-based Linux | [3.0.4](https://grafanarel.s3.amazonaws.com/builds/grafana_3.0.4-1464167696_amd64.deb)
-Beta .deb for Debian-based Linux | [3.1.0-beta1](https://grafanarel.s3.amazonaws.com/builds/grafana_3.1.0-1466666977beta1_amd64.deb)
+Stable .deb for Debian-based Linux | [3.1.0](https://grafanarel.s3.amazonaws.com/builds/grafana_3.1.0-1468321182_amd64.deb)
 
 
 ## Install Stable
 ## Install Stable
 
 
-    $ wget https://grafanarel.s3.amazonaws.com/builds/grafana_3.0.4-1464167696_amd64.deb
+    $ wget https://grafanarel.s3.amazonaws.com/builds/grafana_3.1.0-1468321182_amd64.deb
     $ sudo apt-get install -y adduser libfontconfig
     $ sudo apt-get install -y adduser libfontconfig
-    $ sudo dpkg -i grafana_3.0.4-1464167696_amd64.deb
-
-## Install 3.1 beta
-
-    $ wget https://grafanarel.s3.amazonaws.com/builds/grafana_3.1.0-1466666977beta1_amd64.deb
-    $ sudo apt-get install -y adduser libfontconfig
-    $ sudo dpkg -i grafana_3.1.0-1466666977beta1_amd64.deb
+    $ sudo dpkg -i grafana_3.1.0-1468321182_amd64.deb
 
 
 ## APT Repository
 ## APT Repository
 
 

+ 4 - 22
docs/sources/installation/rpm.md

@@ -10,42 +10,24 @@ page_keywords: grafana, installation, centos, fedora, opensuse, redhat, guide
 
 
 Description | Download
 Description | Download
 ------------ | -------------
 ------------ | -------------
-Stable .RPM for CentOS / Fedora / OpenSuse / Redhat Linux | [3.0.4 (x86-64 rpm)](https://grafanarel.s3.amazonaws.com/builds/grafana-3.0.4-1464167696.x86_64.rpm)
-Beta .RPM for CentOS / Fedora / OpenSuse / Redhat Linux | [3.1.0-beta1 (x86-64 rpm)](https://grafanarel.s3.amazonaws.com/builds/grafana-3.1.0-1466666977beta1.x86_64.rpm)
+Stable .RPM for CentOS / Fedora / OpenSuse / Redhat Linux | [3.1.0 (x86-64 rpm)](https://grafanarel.s3.amazonaws.com/builds/grafana-3.1.0-1468321182.x86_64.rpm)
 
 
 ## Install Latest Stable
 ## Install Latest Stable
 
 
 You can install Grafana using Yum directly.
 You can install Grafana using Yum directly.
 
 
-    $ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-3.0.4-1464167696.x86_64.rpm
+    $ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-3.1.0-1468321182.x86_64.rpm
 
 
 Or install manually using `rpm`.
 Or install manually using `rpm`.
 
 
 #### On CentOS / Fedora / Redhat:
 #### On CentOS / Fedora / Redhat:
 
 
     $ sudo yum install initscripts fontconfig
     $ sudo yum install initscripts fontconfig
-    $ sudo rpm -Uvh grafana-3.0.4-1464167696.x86_64.rpm
+    $ sudo rpm -Uvh grafana-3.1.0-1468321182.x86_64.rpm
 
 
 #### On OpenSuse:
 #### On OpenSuse:
 
 
-    $ sudo rpm -i --nodeps grafana-3.0.4-1464167696.x86_64.rpm
-
-## Install 3.1 Beta
-
-You can install Grafana using Yum directly.
-
-    $ sudo yum install https://grafanarel.s3.amazonaws.com/builds/grafana-3.1.0-1466666977beta1.x86_64.rpm
-
-Or install manually using `rpm`.
-
-#### On CentOS / Fedora / Redhat:
-
-    $ sudo yum install initscripts fontconfig
-    $ sudo rpm -Uvh https://grafanarel.s3.amazonaws.com/builds/grafana-3.1.0-1466666977beta1.x86_64.rpm
-
-#### On OpenSuse:
-
-    $ sudo rpm -i --nodeps https://grafanarel.s3.amazonaws.com/builds/grafana-3.1.0-1466666977beta1.x86_64.rpm
+    $ sudo rpm -i --nodeps grafana-3.1.0-1468321182.x86_64.rpm
 
 
 ## Install via YUM Repository
 ## Install via YUM Repository
 
 

+ 1 - 1
docs/sources/installation/windows.md

@@ -10,7 +10,7 @@ page_keywords: grafana, installation, windows guide
 
 
 Description | Download
 Description | Download
 ------------ | -------------
 ------------ | -------------
-Stable Zip package for Windows | [grafana.3.0.4.windows-x64.zip](https://grafanarel.s3.amazonaws.com/winbuilds/dist/grafana-3.0.4.windows-x64.zip)
+Stable Zip package for Windows | [grafana.3.1.0.windows-x64.zip](https://grafanarel.s3.amazonaws.com/winbuilds/dist/grafana-3.1.0.windows-x64.zip)
 
 
 ## Configure
 ## Configure
 
 

+ 2 - 2
package.json

@@ -53,7 +53,7 @@
     "phantomjs-prebuilt": "^2.1.7",
     "phantomjs-prebuilt": "^2.1.7",
     "reflect-metadata": "0.1.2",
     "reflect-metadata": "0.1.2",
     "rxjs": "5.0.0-beta.4",
     "rxjs": "5.0.0-beta.4",
-    "sass-lint": "^1.6.0",
+    "sass-lint": "^1.7.0",
     "systemjs": "0.19.24"
     "systemjs": "0.19.24"
   },
   },
   "engines": {
   "engines": {
@@ -69,7 +69,7 @@
   "dependencies": {
   "dependencies": {
     "eventemitter3": "^1.2.0",
     "eventemitter3": "^1.2.0",
     "grunt-jscs": "~1.5.x",
     "grunt-jscs": "~1.5.x",
-    "grunt-sass-lint": "^0.1.0",
+    "grunt-sass-lint": "^0.2.0",
     "grunt-sync": "^0.4.1",
     "grunt-sync": "^0.4.1",
     "karma-sinon": "^1.0.3",
     "karma-sinon": "^1.0.3",
     "lodash": "^2.4.1",
     "lodash": "^2.4.1",

+ 1 - 1
pkg/api/api.go

@@ -211,7 +211,7 @@ func Register(r *macaron.Macaron) {
 		// Dashboard
 		// Dashboard
 		r.Group("/dashboards", func() {
 		r.Group("/dashboards", func() {
 			r.Combo("/db/:slug").Get(GetDashboard).Delete(DeleteDashboard)
 			r.Combo("/db/:slug").Get(GetDashboard).Delete(DeleteDashboard)
-			r.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), PostDashboard)
+			r.Post("/db", reqEditorRole, bind(m.SaveDashboardCommand{}), wrap(PostDashboard))
 			r.Get("/file/:file", GetDashboardFromJsonFile)
 			r.Get("/file/:file", GetDashboardFromJsonFile)
 			r.Get("/home", wrap(GetHomeDashboard))
 			r.Get("/home", wrap(GetHomeDashboard))
 			r.Get("/tags", GetDashboardTags)
 			r.Get("/tags", GetDashboardTags)

+ 17 - 14
pkg/api/dashboard.go

@@ -12,6 +12,7 @@ import (
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 	"github.com/grafana/grafana/pkg/util"
@@ -109,7 +110,7 @@ func DeleteDashboard(c *middleware.Context) {
 	c.JSON(200, resp)
 	c.JSON(200, resp)
 }
 }
 
 
-func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) {
+func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 	cmd.OrgId = c.OrgId
 	cmd.OrgId = c.OrgId
 
 
 	if !c.IsSignedIn {
 	if !c.IsSignedIn {
@@ -122,35 +123,37 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) {
 	if dash.Id == 0 {
 	if dash.Id == 0 {
 		limitReached, err := middleware.QuotaReached(c, "dashboard")
 		limitReached, err := middleware.QuotaReached(c, "dashboard")
 		if err != nil {
 		if err != nil {
-			c.JsonApiErr(500, "failed to get quota", err)
-			return
+			return ApiError(500, "failed to get quota", err)
 		}
 		}
 		if limitReached {
 		if limitReached {
-			c.JsonApiErr(403, "Quota reached", nil)
-			return
+			return ApiError(403, "Quota reached", nil)
 		}
 		}
 	}
 	}
 
 
 	err := bus.Dispatch(&cmd)
 	err := bus.Dispatch(&cmd)
 	if err != nil {
 	if err != nil {
 		if err == m.ErrDashboardWithSameNameExists {
 		if err == m.ErrDashboardWithSameNameExists {
-			c.JSON(412, util.DynMap{"status": "name-exists", "message": err.Error()})
-			return
+			return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
 		}
 		}
 		if err == m.ErrDashboardVersionMismatch {
 		if err == m.ErrDashboardVersionMismatch {
-			c.JSON(412, util.DynMap{"status": "version-mismatch", "message": err.Error()})
-			return
+			return Json(412, util.DynMap{"status": "version-mismatch", "message": err.Error()})
+		}
+		if pluginErr, ok := err.(m.UpdatePluginDashboardError); ok {
+			message := "The dashboard belongs to plugin " + pluginErr.PluginId + "."
+			// look up plugin name
+			if pluginDef, exist := plugins.Plugins[pluginErr.PluginId]; exist {
+				message = "The dashboard belongs to plugin " + pluginDef.Name + "."
+			}
+			return Json(412, util.DynMap{"status": "plugin-dashboard", "message": message})
 		}
 		}
 		if err == m.ErrDashboardNotFound {
 		if err == m.ErrDashboardNotFound {
-			c.JSON(404, util.DynMap{"status": "not-found", "message": err.Error()})
-			return
+			return Json(404, util.DynMap{"status": "not-found", "message": err.Error()})
 		}
 		}
-		c.JsonApiErr(500, "Failed to save dashboard", err)
-		return
+		return ApiError(500, "Failed to save dashboard", err)
 	}
 	}
 
 
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
-	c.JSON(200, util.DynMap{"status": "success", "slug": cmd.Result.Slug, "version": cmd.Result.Version})
+	return Json(200, util.DynMap{"status": "success", "slug": cmd.Result.Slug, "version": cmd.Result.Version})
 }
 }
 
 
 func canEditDashboard(role m.RoleType) bool {
 func canEditDashboard(role m.RoleType) bool {

+ 1 - 1
pkg/api/playlist_play.go

@@ -18,7 +18,7 @@ func populateDashboardsById(dashboardByIds []int64) ([]m.PlaylistDashboardDto, e
 			return result, err
 			return result, err
 		}
 		}
 
 
-		for _, item := range *dashboardQuery.Result {
+		for _, item := range dashboardQuery.Result {
 			result = append(result, m.PlaylistDashboardDto{
 			result = append(result, m.PlaylistDashboardDto{
 				Id:    item.Id,
 				Id:    item.Id,
 				Slug:  item.Slug,
 				Slug:  item.Slug,

+ 23 - 6
pkg/models/dashboards.go

@@ -17,6 +17,14 @@ var (
 	ErrDashboardVersionMismatch    = errors.New("The dashboard has been changed by someone else")
 	ErrDashboardVersionMismatch    = errors.New("The dashboard has been changed by someone else")
 )
 )
 
 
+type UpdatePluginDashboardError struct {
+	PluginId string
+}
+
+func (d UpdatePluginDashboardError) Error() string {
+	return "Dashboard belong to plugin"
+}
+
 var (
 var (
 	DashTypeJson     = "file"
 	DashTypeJson     = "file"
 	DashTypeDB       = "db"
 	DashTypeDB       = "db"
@@ -26,11 +34,12 @@ var (
 
 
 // Dashboard model
 // Dashboard model
 type Dashboard struct {
 type Dashboard struct {
-	Id      int64
-	Slug    string
-	OrgId   int64
-	GnetId  int64
-	Version int
+	Id       int64
+	Slug     string
+	OrgId    int64
+	GnetId   int64
+	Version  int
+	PluginId string
 
 
 	Created time.Time
 	Created time.Time
 	Updated time.Time
 	Updated time.Time
@@ -95,6 +104,7 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard {
 
 
 	dash.UpdatedBy = cmd.UserId
 	dash.UpdatedBy = cmd.UserId
 	dash.OrgId = cmd.OrgId
 	dash.OrgId = cmd.OrgId
+	dash.PluginId = cmd.PluginId
 	dash.UpdateSlug()
 	dash.UpdateSlug()
 	return dash
 	return dash
 }
 }
@@ -119,6 +129,7 @@ type SaveDashboardCommand struct {
 	UserId    int64            `json:"userId"`
 	UserId    int64            `json:"userId"`
 	OrgId     int64            `json:"-"`
 	OrgId     int64            `json:"-"`
 	Overwrite bool             `json:"overwrite"`
 	Overwrite bool             `json:"overwrite"`
+	PluginId  string           `json:"-"`
 
 
 	Result *Dashboard
 	Result *Dashboard
 }
 }
@@ -151,7 +162,13 @@ type GetDashboardTagsQuery struct {
 
 
 type GetDashboardsQuery struct {
 type GetDashboardsQuery struct {
 	DashboardIds []int64
 	DashboardIds []int64
-	Result       *[]Dashboard
+	Result       []*Dashboard
+}
+
+type GetDashboardsByPluginIdQuery struct {
+	OrgId    int64
+	PluginId string
+	Result   []*Dashboard
 }
 }
 
 
 type GetDashboardSlugByIdQuery struct {
 type GetDashboardSlugByIdQuery struct {

+ 20 - 4
pkg/models/plugin_settings.go

@@ -20,6 +20,7 @@ type PluginSetting struct {
 	Pinned         bool
 	Pinned         bool
 	JsonData       map[string]interface{}
 	JsonData       map[string]interface{}
 	SecureJsonData SecureJsonData
 	SecureJsonData SecureJsonData
+	PluginVersion  string
 
 
 	Created time.Time
 	Created time.Time
 	Updated time.Time
 	Updated time.Time
@@ -44,11 +45,19 @@ type UpdatePluginSettingCmd struct {
 	Pinned         bool                   `json:"pinned"`
 	Pinned         bool                   `json:"pinned"`
 	JsonData       map[string]interface{} `json:"jsonData"`
 	JsonData       map[string]interface{} `json:"jsonData"`
 	SecureJsonData map[string]string      `json:"secureJsonData"`
 	SecureJsonData map[string]string      `json:"secureJsonData"`
+	PluginVersion  string                 `json:"version"`
 
 
 	PluginId string `json:"-"`
 	PluginId string `json:"-"`
 	OrgId    int64  `json:"-"`
 	OrgId    int64  `json:"-"`
 }
 }
 
 
+// specific command, will only update version
+type UpdatePluginSettingVersionCmd struct {
+	PluginVersion string
+	PluginId      string `json:"-"`
+	OrgId         int64  `json:"-"`
+}
+
 func (cmd *UpdatePluginSettingCmd) GetEncryptedJsonData() SecureJsonData {
 func (cmd *UpdatePluginSettingCmd) GetEncryptedJsonData() SecureJsonData {
 	encrypted := make(SecureJsonData)
 	encrypted := make(SecureJsonData)
 	for key, data := range cmd.SecureJsonData {
 	for key, data := range cmd.SecureJsonData {
@@ -65,10 +74,11 @@ type GetPluginSettingsQuery struct {
 }
 }
 
 
 type PluginSettingInfoDTO struct {
 type PluginSettingInfoDTO struct {
-	OrgId    int64
-	PluginId string
-	Enabled  bool
-	Pinned   bool
+	OrgId         int64
+	PluginId      string
+	Enabled       bool
+	Pinned        bool
+	PluginVersion string
 }
 }
 
 
 type GetPluginSettingByIdQuery struct {
 type GetPluginSettingByIdQuery struct {
@@ -76,3 +86,9 @@ type GetPluginSettingByIdQuery struct {
 	OrgId    int64
 	OrgId    int64
 	Result   *PluginSetting
 	Result   *PluginSetting
 }
 }
+
+type PluginStateChangedEvent struct {
+	PluginId string
+	OrgId    int64
+	Enabled  bool
+}

+ 1 - 0
pkg/plugins/dashboard_importer.go

@@ -68,6 +68,7 @@ func ImportDashboard(cmd *ImportDashboardCommand) error {
 		OrgId:     cmd.OrgId,
 		OrgId:     cmd.OrgId,
 		UserId:    cmd.UserId,
 		UserId:    cmd.UserId,
 		Overwrite: cmd.Overwrite,
 		Overwrite: cmd.Overwrite,
+		PluginId:  cmd.PluginId,
 	}
 	}
 
 
 	if err := bus.Dispatch(&saveCmd); err != nil {
 	if err := bus.Dispatch(&saveCmd); err != nil {

+ 46 - 35
pkg/plugins/dashboards.go

@@ -14,10 +14,12 @@ type PluginDashboardInfoDTO struct {
 	Title            string `json:"title"`
 	Title            string `json:"title"`
 	Imported         bool   `json:"imported"`
 	Imported         bool   `json:"imported"`
 	ImportedUri      string `json:"importedUri"`
 	ImportedUri      string `json:"importedUri"`
+	Slug             string `json:"slug"`
 	ImportedRevision int64  `json:"importedRevision"`
 	ImportedRevision int64  `json:"importedRevision"`
 	Revision         int64  `json:"revision"`
 	Revision         int64  `json:"revision"`
 	Description      string `json:"description"`
 	Description      string `json:"description"`
 	Path             string `json:"path"`
 	Path             string `json:"path"`
+	Removed          bool   `json:"removed"`
 }
 }
 
 
 func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDTO, error) {
 func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDTO, error) {
@@ -29,14 +31,53 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT
 
 
 	result := make([]*PluginDashboardInfoDTO, 0)
 	result := make([]*PluginDashboardInfoDTO, 0)
 
 
+	// load current dashboards
+	query := m.GetDashboardsByPluginIdQuery{OrgId: orgId, PluginId: pluginId}
+	if err := bus.Dispatch(&query); err != nil {
+		return nil, err
+	}
+
+	existingMatches := make(map[int64]bool)
+
 	for _, include := range plugin.Includes {
 	for _, include := range plugin.Includes {
-		if include.Type == PluginTypeDashboard {
-			if dashInfo, err := getDashboardImportStatus(orgId, plugin, include.Path); err != nil {
-				return nil, err
-			} else {
-				result = append(result, dashInfo)
+		if include.Type != PluginTypeDashboard {
+			continue
+		}
+
+		res := &PluginDashboardInfoDTO{}
+		var dashboard *m.Dashboard
+		var err error
+
+		if dashboard, err = loadPluginDashboard(plugin.Id, include.Path); err != nil {
+			return nil, err
+		}
+
+		res.Path = include.Path
+		res.PluginId = plugin.Id
+		res.Title = dashboard.Title
+		res.Revision = dashboard.Data.Get("revision").MustInt64(1)
+
+		// find existing dashboard
+		for _, existingDash := range query.Result {
+			if existingDash.Slug == dashboard.Slug {
+				res.Imported = true
+				res.ImportedUri = "db/" + existingDash.Slug
+				res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1)
+				existingMatches[existingDash.Id] = true
 			}
 			}
 		}
 		}
+
+		result = append(result, res)
+	}
+
+	// find deleted dashboards
+	for _, dash := range query.Result {
+		if _, exists := existingMatches[dash.Id]; !exists {
+			result = append(result, &PluginDashboardInfoDTO{
+				Slug:    dash.Slug,
+				Removed: true,
+			})
+		}
 	}
 	}
 
 
 	return result, nil
 	return result, nil
@@ -64,33 +105,3 @@ func loadPluginDashboard(pluginId, path string) (*m.Dashboard, error) {
 
 
 	return m.NewDashboardFromJson(data), nil
 	return m.NewDashboardFromJson(data), nil
 }
 }
-
-func getDashboardImportStatus(orgId int64, plugin *PluginBase, path string) (*PluginDashboardInfoDTO, error) {
-	res := &PluginDashboardInfoDTO{}
-
-	var dashboard *m.Dashboard
-	var err error
-
-	if dashboard, err = loadPluginDashboard(plugin.Id, path); err != nil {
-		return nil, err
-	}
-
-	res.Path = path
-	res.PluginId = plugin.Id
-	res.Title = dashboard.Title
-	res.Revision = dashboard.Data.Get("revision").MustInt64(1)
-
-	query := m.GetDashboardQuery{OrgId: orgId, Slug: dashboard.Slug}
-
-	if err := bus.Dispatch(&query); err != nil {
-		if err != m.ErrDashboardNotFound {
-			return nil, err
-		}
-	} else {
-		res.Imported = true
-		res.ImportedUri = "db/" + query.Result.Slug
-		res.ImportedRevision = query.Result.Data.Get("revision").MustInt64(1)
-	}
-
-	return res, nil
-}

+ 17 - 5
pkg/plugins/dashboards_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 	"testing"
 
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
@@ -31,6 +32,17 @@ func TestPluginDashboards(t *testing.T) {
 			return m.ErrDashboardNotFound
 			return m.ErrDashboardNotFound
 		})
 		})
 
 
+		bus.AddHandler("test", func(query *m.GetDashboardsByPluginIdQuery) error {
+			var data = simplejson.New()
+			data.Set("title", "Nginx Connections")
+			data.Set("revision", 22)
+
+			query.Result = []*m.Dashboard{
+				{Slug: "nginx-connections", Data: data},
+			}
+			return nil
+		})
+
 		dashboards, err := GetPluginDashboards(1, "test-app")
 		dashboards, err := GetPluginDashboards(1, "test-app")
 
 
 		So(err, ShouldBeNil)
 		So(err, ShouldBeNil)
@@ -41,12 +53,12 @@ func TestPluginDashboards(t *testing.T) {
 
 
 		Convey("should include installed version info", func() {
 		Convey("should include installed version info", func() {
 			So(dashboards[0].Title, ShouldEqual, "Nginx Connections")
 			So(dashboards[0].Title, ShouldEqual, "Nginx Connections")
-			//So(dashboards[0].Revision, ShouldEqual, "1.5")
-			//So(dashboards[0].InstalledRevision, ShouldEqual, "1.1")
-			//So(dashboards[0].InstalledUri, ShouldEqual, "db/nginx-connections")
+			So(dashboards[0].Revision, ShouldEqual, 25)
+			So(dashboards[0].ImportedRevision, ShouldEqual, 22)
+			So(dashboards[0].ImportedUri, ShouldEqual, "db/nginx-connections")
 
 
-			//So(dashboards[1].Revision, ShouldEqual, "2.0")
-			//So(dashboards[1].InstalledRevision, ShouldEqual, "")
+			So(dashboards[1].Revision, ShouldEqual, 2)
+			So(dashboards[1].ImportedRevision, ShouldEqual, 0)
 		})
 		})
 	})
 	})
 
 

+ 139 - 0
pkg/plugins/dashboards_updater.go

@@ -0,0 +1,139 @@
+package plugins
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+)
+
+func init() {
+	bus.AddEventListener(handlePluginStateChanged)
+}
+
+func updateAppDashboards() {
+	time.Sleep(time.Second * 5)
+
+	plog.Debug("Looking for App Dashboard Updates")
+
+	query := m.GetPluginSettingsQuery{OrgId: 0}
+
+	if err := bus.Dispatch(&query); err != nil {
+		plog.Error("Failed to get all plugin settings", "error", err)
+		return
+	}
+
+	for _, pluginSetting := range query.Result {
+		// ignore disabled plugins
+		if !pluginSetting.Enabled {
+			continue
+		}
+
+		if pluginDef, exist := Plugins[pluginSetting.PluginId]; exist {
+			if pluginDef.Info.Version != pluginSetting.PluginVersion {
+				syncPluginDashboards(pluginDef, pluginSetting.OrgId)
+			}
+		}
+	}
+}
+
+func autoUpdateAppDashboard(pluginDashInfo *PluginDashboardInfoDTO, orgId int64) error {
+	if dash, err := loadPluginDashboard(pluginDashInfo.PluginId, pluginDashInfo.Path); err != nil {
+		return err
+	} else {
+		plog.Info("Auto updating App dashboard", "dashboard", dash.Title, "newRev", pluginDashInfo.Revision, "oldRev", pluginDashInfo.ImportedRevision)
+		updateCmd := ImportDashboardCommand{
+			OrgId:     orgId,
+			PluginId:  pluginDashInfo.PluginId,
+			Overwrite: true,
+			Dashboard: dash.Data,
+			UserId:    0,
+			Path:      pluginDashInfo.Path,
+		}
+
+		if err := bus.Dispatch(&updateCmd); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func syncPluginDashboards(pluginDef *PluginBase, orgId int64) {
+	plog.Info("Syncing plugin dashboards to DB", "pluginId", pluginDef.Id)
+
+	// Get plugin dashboards
+	dashboards, err := GetPluginDashboards(orgId, pluginDef.Id)
+
+	if err != nil {
+		plog.Error("Failed to load app dashboards", "error", err)
+		return
+	}
+
+	// Update dashboards with updated revisions
+	for _, dash := range dashboards {
+		// remove removed ones
+		if dash.Removed {
+			plog.Info("Deleting plugin dashboard", "pluginId", pluginDef.Id, "dashboard", dash.Slug)
+
+			deleteCmd := m.DeleteDashboardCommand{OrgId: orgId, Slug: dash.Slug}
+			if err := bus.Dispatch(&deleteCmd); err != nil {
+				plog.Error("Failed to auto update app dashboard", "pluginId", pluginDef.Id, "error", err)
+				return
+			}
+
+			continue
+		}
+
+		// update updated ones
+		if dash.ImportedRevision != dash.Revision {
+			if err := autoUpdateAppDashboard(dash, orgId); err != nil {
+				plog.Error("Failed to auto update app dashboard", "pluginId", pluginDef.Id, "error", err)
+				return
+			}
+		}
+	}
+
+	// update version in plugin_setting table to mark that we have processed the update
+	query := m.GetPluginSettingByIdQuery{PluginId: pluginDef.Id, OrgId: orgId}
+	if err := bus.Dispatch(&query); err != nil {
+		plog.Error("Failed to read plugin setting by id", "error", err)
+		return
+	}
+
+	appSetting := query.Result
+	cmd := m.UpdatePluginSettingVersionCmd{
+		OrgId:         appSetting.OrgId,
+		PluginId:      appSetting.PluginId,
+		PluginVersion: pluginDef.Info.Version,
+	}
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		plog.Error("Failed to update plugin setting version", "error", err)
+	}
+}
+
+func handlePluginStateChanged(event *m.PluginStateChangedEvent) error {
+	plog.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled)
+
+	if event.Enabled {
+		syncPluginDashboards(Plugins[event.PluginId], event.OrgId)
+	} else {
+		query := m.GetDashboardsByPluginIdQuery{PluginId: event.PluginId, OrgId: event.OrgId}
+
+		if err := bus.Dispatch(&query); err != nil {
+			return err
+		} else {
+			for _, dash := range query.Result {
+				deleteCmd := m.DeleteDashboardCommand{OrgId: dash.OrgId, Slug: dash.Slug}
+
+				plog.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug)
+
+				if err := bus.Dispatch(&deleteCmd); err != nil {
+					return err
+				}
+			}
+		}
+	}
+
+	return nil
+}

+ 2 - 0
pkg/plugins/plugins.go

@@ -77,6 +77,8 @@ func Init() error {
 	}
 	}
 
 
 	go StartPluginUpdateChecker()
 	go StartPluginUpdateChecker()
+	go updateAppDashboards()
+
 	return nil
 	return nil
 }
 }
 
 

+ 21 - 2
pkg/services/sqlstore/dashboard.go

@@ -19,6 +19,7 @@ func init() {
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardSlugById)
 	bus.AddHandler("sql", GetDashboardSlugById)
+	bus.AddHandler("sql", GetDashboardsByPluginId)
 }
 }
 
 
 func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 func SaveDashboard(cmd *m.SaveDashboardCommand) error {
@@ -45,6 +46,11 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 					return m.ErrDashboardVersionMismatch
 					return m.ErrDashboardVersionMismatch
 				}
 				}
 			}
 			}
+
+			// do not allow plugin dashboard updates without overwrite flag
+			if existing.PluginId != "" && cmd.Overwrite == false {
+				return m.UpdatePluginDashboardError{PluginId: existing.PluginId}
+			}
 		}
 		}
 
 
 		sameTitleExists, err := sess.Where("org_id=? AND slug=?", dash.OrgId, dash.Slug).Get(&sameTitle)
 		sameTitleExists, err := sess.Where("org_id=? AND slug=?", dash.OrgId, dash.Slug).Get(&sameTitle)
@@ -245,10 +251,23 @@ func GetDashboards(query *m.GetDashboardsQuery) error {
 		return m.ErrCommandValidationFailed
 		return m.ErrCommandValidationFailed
 	}
 	}
 
 
-	var dashboards = make([]m.Dashboard, 0)
+	var dashboards = make([]*m.Dashboard, 0)
 
 
 	err := x.In("id", query.DashboardIds).Find(&dashboards)
 	err := x.In("id", query.DashboardIds).Find(&dashboards)
-	query.Result = &dashboards
+	query.Result = dashboards
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func GetDashboardsByPluginId(query *m.GetDashboardsByPluginIdQuery) error {
+	var dashboards = make([]*m.Dashboard, 0)
+
+	err := x.Where("org_id=? AND plugin_id=?", query.OrgId, query.PluginId).Find(&dashboards)
+	query.Result = dashboards
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err

+ 9 - 0
pkg/services/sqlstore/migrations/dashboard_mig.go

@@ -111,4 +111,13 @@ func addDashboardMigration(mg *Migrator) {
 	mg.AddMigration("Add index for gnetId in dashboard", NewAddIndexMigration(dashboardV2, &Index{
 	mg.AddMigration("Add index for gnetId in dashboard", NewAddIndexMigration(dashboardV2, &Index{
 		Cols: []string{"gnet_id"}, Type: IndexType,
 		Cols: []string{"gnet_id"}, Type: IndexType,
 	}))
 	}))
+
+	// add column to store plugin_id
+	mg.AddMigration("Add column plugin_id in dashboard", NewAddColumnMigration(dashboardV2, &Column{
+		Name: "plugin_id", Type: DB_NVarchar, Nullable: true, Length: 255,
+	}))
+
+	mg.AddMigration("Add index for plugin_id in dashboard", NewAddIndexMigration(dashboardV2, &Index{
+		Cols: []string{"org_id", "plugin_id"}, Type: IndexType,
+	}))
 }
 }

+ 6 - 0
pkg/services/sqlstore/migrations/plugin_setting.go

@@ -26,4 +26,10 @@ func addAppSettingsMigration(mg *Migrator) {
 
 
 	//-------  indexes ------------------
 	//-------  indexes ------------------
 	addTableIndicesMigrations(mg, "v1", pluginSettingTable)
 	addTableIndicesMigrations(mg, "v1", pluginSettingTable)
+
+	// add column to store installed version
+	mg.AddMigration("Add column plugin_version to plugin_settings", NewAddColumnMigration(pluginSettingTable, &Column{
+		Name: "plugin_version", Type: DB_NVarchar, Nullable: true, Length: 50,
+	}))
+
 }
 }

+ 40 - 4
pkg/services/sqlstore/plugin_setting.go

@@ -13,14 +13,20 @@ func init() {
 	bus.AddHandler("sql", GetPluginSettings)
 	bus.AddHandler("sql", GetPluginSettings)
 	bus.AddHandler("sql", GetPluginSettingById)
 	bus.AddHandler("sql", GetPluginSettingById)
 	bus.AddHandler("sql", UpdatePluginSetting)
 	bus.AddHandler("sql", UpdatePluginSetting)
+	bus.AddHandler("sql", UpdatePluginSettingVersion)
 }
 }
 
 
 func GetPluginSettings(query *m.GetPluginSettingsQuery) error {
 func GetPluginSettings(query *m.GetPluginSettingsQuery) error {
-	sql := `SELECT org_id, plugin_id, enabled, pinned
-					FROM plugin_setting
-					WHERE org_id=?`
+	sql := `SELECT org_id, plugin_id, enabled, pinned, plugin_version
+					FROM plugin_setting `
+	params := make([]interface{}, 0)
 
 
-	sess := x.Sql(sql, query.OrgId)
+	if query.OrgId != 0 {
+		sql += "WHERE org_id=?"
+		params = append(params, query.OrgId)
+	}
+
+	sess := x.Sql(sql, params...)
 	query.Result = make([]*m.PluginSettingInfoDTO, 0)
 	query.Result = make([]*m.PluginSettingInfoDTO, 0)
 	return sess.Find(&query.Result)
 	return sess.Find(&query.Result)
 }
 }
@@ -51,22 +57,52 @@ func UpdatePluginSetting(cmd *m.UpdatePluginSettingCmd) error {
 				Enabled:        cmd.Enabled,
 				Enabled:        cmd.Enabled,
 				Pinned:         cmd.Pinned,
 				Pinned:         cmd.Pinned,
 				JsonData:       cmd.JsonData,
 				JsonData:       cmd.JsonData,
+				PluginVersion:  cmd.PluginVersion,
 				SecureJsonData: cmd.GetEncryptedJsonData(),
 				SecureJsonData: cmd.GetEncryptedJsonData(),
 				Created:        time.Now(),
 				Created:        time.Now(),
 				Updated:        time.Now(),
 				Updated:        time.Now(),
 			}
 			}
+
+			// add state change event on commit success
+			sess.events = append(sess.events, &m.PluginStateChangedEvent{
+				PluginId: cmd.PluginId,
+				OrgId:    cmd.OrgId,
+				Enabled:  cmd.Enabled,
+			})
+
 			_, err = sess.Insert(&pluginSetting)
 			_, err = sess.Insert(&pluginSetting)
 			return err
 			return err
 		} else {
 		} else {
 			for key, data := range cmd.SecureJsonData {
 			for key, data := range cmd.SecureJsonData {
 				pluginSetting.SecureJsonData[key] = util.Encrypt([]byte(data), setting.SecretKey)
 				pluginSetting.SecureJsonData[key] = util.Encrypt([]byte(data), setting.SecretKey)
 			}
 			}
+
+			// add state change event on commit success
+			if pluginSetting.Enabled != cmd.Enabled {
+				sess.events = append(sess.events, &m.PluginStateChangedEvent{
+					PluginId: cmd.PluginId,
+					OrgId:    cmd.OrgId,
+					Enabled:  cmd.Enabled,
+				})
+			}
+
 			pluginSetting.Updated = time.Now()
 			pluginSetting.Updated = time.Now()
 			pluginSetting.Enabled = cmd.Enabled
 			pluginSetting.Enabled = cmd.Enabled
 			pluginSetting.JsonData = cmd.JsonData
 			pluginSetting.JsonData = cmd.JsonData
 			pluginSetting.Pinned = cmd.Pinned
 			pluginSetting.Pinned = cmd.Pinned
+			pluginSetting.PluginVersion = cmd.PluginVersion
+
 			_, err = sess.Id(pluginSetting.Id).Update(&pluginSetting)
 			_, err = sess.Id(pluginSetting.Id).Update(&pluginSetting)
 			return err
 			return err
 		}
 		}
 	})
 	})
 }
 }
+
+func UpdatePluginSettingVersion(cmd *m.UpdatePluginSettingVersionCmd) error {
+	return inTransaction2(func(sess *session) error {
+
+		_, err := sess.Exec("UPDATE plugin_setting SET plugin_version=? WHERE org_id=? AND plugin_id=?", cmd.PluginVersion, cmd.OrgId, cmd.PluginId)
+		return err
+
+	})
+}

+ 4 - 0
public/app/core/components/search/search.html

@@ -72,6 +72,10 @@
 			Import
 			Import
 		</a>
 		</a>
 
 
+		<a class="pull-right search-button-row-explore-link" target="_blank" href="https://grafana.net/dashboards?utm_source=grafana_search">
+      Find dashboards on
+		</a>
+
  		<div class="clearfix"></div>
  		<div class="clearfix"></div>
 	</div>
 	</div>
 </div>
 </div>

+ 2 - 0
public/app/core/services/alert_srv.ts

@@ -73,6 +73,8 @@ export class AlertSrv {
     scope.text = payload.text;
     scope.text = payload.text;
     scope.text2 = payload.text2;
     scope.text2 = payload.text2;
     scope.onConfirm = payload.onConfirm;
     scope.onConfirm = payload.onConfirm;
+    scope.onAltAction = payload.onAltAction;
+    scope.altActionText = payload.altActionText;
     scope.icon = payload.icon || "fa-check";
     scope.icon = payload.icon || "fa-check";
     scope.yesText = payload.yesText || "Yes";
     scope.yesText = payload.yesText || "Yes";
     scope.noText = payload.noText || "Cancel";
     scope.noText = payload.noText || "Cancel";

+ 1 - 0
public/app/features/dashboard/dashboardSrv.js

@@ -22,6 +22,7 @@ function (angular, $, _, moment) {
 
 
       this.id = data.id || null;
       this.id = data.id || null;
       this.title = data.title || 'No Title';
       this.title = data.title || 'No Title';
+      this.autoUpdate = data.autoUpdate;
       this.description = data.description;
       this.description = data.description;
       this.tags = data.tags || [];
       this.tags = data.tags || [];
       this.style = data.style || "dark";
       this.style = data.style || "dark";

+ 19 - 0
public/app/features/dashboard/dashnav/dashnav.ts

@@ -134,6 +134,25 @@ export class DashNavCtrl {
           }
           }
         });
         });
       }
       }
+
+      if (err.data && err.data.status === "plugin-dashboard") {
+        err.isHandled = true;
+
+        $scope.appEvent('confirm-modal', {
+          title: 'Plugin Dashboard',
+          text: err.data.message,
+          text2: 'Your changes will be lost when you update the plugin. Use Save As to create custom version.',
+          yesText: "Overwrite",
+          icon: "fa-warning",
+          altActionText: "Save As",
+          onAltAction: function() {
+            $scope.saveDashboardAs();
+          },
+          onConfirm: function() {
+            $scope.saveDashboard({overwrite: true});
+          }
+        });
+      }
     };
     };
 
 
     $scope.deleteDashboard = function() {
     $scope.deleteDashboard = function() {

+ 5 - 2
public/app/features/dashboard/saveDashboardAsCtrl.js

@@ -12,6 +12,8 @@ function (angular) {
       $scope.clone.id = null;
       $scope.clone.id = null;
       $scope.clone.editable = true;
       $scope.clone.editable = true;
       $scope.clone.title = $scope.clone.title + " Copy";
       $scope.clone.title = $scope.clone.title + " Copy";
+      // remove auto update
+      delete $scope.clone.autoUpdate;
     };
     };
 
 
     function saveDashboard(options) {
     function saveDashboard(options) {
@@ -37,8 +39,9 @@ function (angular) {
           err.isHandled = true;
           err.isHandled = true;
 
 
           $scope.appEvent('confirm-modal', {
           $scope.appEvent('confirm-modal', {
-            title: 'Another dashboard with the same name exists',
-            text: "Would you still like to save this dashboard?",
+            title: 'Conflict',
+            text: 'Dashboard with the same name exists.',
+            text2: 'Would you still like to save this dashboard?',
             yesText: "Save & Overwrite",
             yesText: "Save & Overwrite",
             icon: "fa-warning",
             icon: "fa-warning",
             onConfirm: function() {
             onConfirm: function() {

+ 7 - 8
public/app/features/plugins/import_list/import_list.html

@@ -14,20 +14,19 @@
 					</span>
 					</span>
 				</td>
 				</td>
 				<td>
 				<td>
-					v{{dash.revision}}
-					<span ng-if="dash.installed">
-						&nbsp;(Imported v{{dash.importedRevision}})
-					<span>
+          <span ng-if="dash.imported" bs-tooltip='"Imported revision:" + dash.importedRevision'>
+            Revision: {{dash.revision}}
+          <span>
 				</td>
 				</td>
 				<td style="text-align: right">
 				<td style="text-align: right">
-					<button class="btn btn-secondary" ng-click="ctrl.import(dash, false)" ng-show="!dash.imported">
+					<button class="btn btn-secondary btn-small" ng-click="ctrl.import(dash, false)" ng-show="!dash.imported">
 						Import
 						Import
 					</button>
 					</button>
-					<button class="btn btn-secondary" ng-click="ctrl.import(dash, true)" ng-show="dash.imported">
+					<button class="btn btn-secondary btn-small" ng-click="ctrl.import(dash, true)" ng-show="dash.imported">
 						Update
 						Update
 					</button>
 					</button>
-					<button class="btn btn-danger" ng-click="ctrl.remove(dash)" ng-show="dash.imported">
-						Delete
+					<button class="btn btn-danger btn-small" ng-click="ctrl.remove(dash)" ng-show="dash.imported">
+						<i class="fa fa-trash"></i>
 					</button>
 					</button>
 				</td>
 				</td>
 			</tr>
 			</tr>

+ 36 - 28
public/app/features/plugins/partials/plugin_list.html

@@ -5,6 +5,10 @@
   <div class="page-header">
   <div class="page-header">
     <h1>Plugins</h1>
     <h1>Plugins</h1>
 
 
+		<!-- <a class="btn btn&#45;inverse" href="https://grafana.net/plugins?utm_source=grafana_plugin_list" target="_blank"> -->
+		<!-- 	Explore plugins on Grafana.net -->
+		<!-- </a> -->
+
 		<div class="page-header-tabs">
 		<div class="page-header-tabs">
 			<ul class="gf-tabs">
 			<ul class="gf-tabs">
 				<li class="gf-tabs-item">
 				<li class="gf-tabs-item">
@@ -23,36 +27,40 @@
 					</a>
 					</a>
 				</li>
 				</li>
 			</ul>
 			</ul>
-		</div>
-	</div>
 
 
-	<section class="card-section" layout-mode>
+      <a class="get-more-plugins-link" href="https://grafana.net/plugins?utm_source=grafana_plugin_list" target="_blank">
+        Find plugins on
+      </a>
+    </div>
+  </div>
+
+  <section class="card-section" layout-mode>
     <layout-selector></layout-selector>
     <layout-selector></layout-selector>
 
 
-		<ol class="card-list" >
-			<li class="card-item-wrapper" ng-repeat="plugin in ctrl.plugins">
-				<a class="card-item" href="plugins/{{plugin.id}}/edit">
-					<div class="card-item-header">
-						<div class="card-item-type">
-							<i class="icon-gf icon-gf-{{plugin.type}}"></i>
-							{{plugin.type}}
-						</div>
-					  <div class="card-item-notice" ng-show="plugin.hasUpdate">
-							<span bs-tooltip="plugin.latestVersion">Update available!</span>
-						</div>
-					</div>
-					<div class="card-item-body">
-						<figure class="card-item-figure">
-							<img ng-src="{{plugin.info.logos.small}}">
-						</figure>
-						<div class="card-item-details">
-							<div class="card-item-name">{{plugin.name}}</div>
-							<div class="card-item-sub-name">By {{plugin.info.author.name}}</div>
-						</div>
-					</div>
-				</a>
-			</li>
-		</ol>
-	</section>
+    <ol class="card-list" >
+      <li class="card-item-wrapper" ng-repeat="plugin in ctrl.plugins">
+        <a class="card-item" href="plugins/{{plugin.id}}/edit">
+          <div class="card-item-header">
+            <div class="card-item-type">
+              <i class="icon-gf icon-gf-{{plugin.type}}"></i>
+              {{plugin.type}}
+            </div>
+            <div class="card-item-notice" ng-show="plugin.hasUpdate">
+              <span bs-tooltip="plugin.latestVersion">Update available!</span>
+            </div>
+          </div>
+          <div class="card-item-body">
+            <figure class="card-item-figure">
+              <img ng-src="{{plugin.info.logos.small}}">
+            </figure>
+            <div class="card-item-details">
+              <div class="card-item-name">{{plugin.name}}</div>
+              <div class="card-item-sub-name">By {{plugin.info.author.name}}</div>
+            </div>
+          </div>
+        </a>
+      </li>
+    </ol>
+  </section>
 
 
 </div>
 </div>

+ 1 - 22
public/app/features/plugins/plugin_edit_ctrl.ts

@@ -97,28 +97,7 @@ export class PluginEditCtrl {
   }
   }
 
 
   importDashboards() {
   importDashboards() {
-    // move to dashboards tab
-    this.tabIndex = 2;
-
-    return new Promise((resolve) => {
-      if (!this.$scope.$$phase) {
-        this.$scope.$digest();
-      }
-
-      // let angular load dashboards tab
-      setTimeout(() => {
-        resolve();
-      }, 1000);
-
-    }).then(() => {
-      return new Promise((resolve, reject) => {
-        // send event to import list component
-        appEvents.emit('dashboard-list-import-all', {
-          resolve: resolve,
-          reject: reject
-        });
-      });
-    });
+    return Promise.resolve();
   }
   }
 
 
   setPreUpdateHook(callback: () => any) {
   setPreUpdateHook(callback: () => any) {

+ 2 - 1
public/app/partials/confirm_modal.html

@@ -1,4 +1,4 @@
-<div class="modal-body">
+<div class="modal-body" ng-cloak>
 	<div class="modal-header">
 	<div class="modal-header">
 		<h2 class="modal-header-title">
 		<h2 class="modal-header-title">
 			<i class="fa {{icon}}"></i>
 			<i class="fa {{icon}}"></i>
@@ -24,6 +24,7 @@
 		<div class="confirm-modal-buttons">
 		<div class="confirm-modal-buttons">
 			<button type="button" class="btn btn-inverse" ng-click="dismiss()">{{noText}}</button>
 			<button type="button" class="btn btn-inverse" ng-click="dismiss()">{{noText}}</button>
 			<button type="button" class="btn btn-danger" ng-click="onConfirm();dismiss();">{{yesText}}</button>
 			<button type="button" class="btn btn-danger" ng-click="onConfirm();dismiss();">{{yesText}}</button>
+			<button ng-show="onAltAction" type="button" class="btn btn-success" ng-click="dismiss();onAltAction();">{{altActionText}}</button>
 		</div>
 		</div>
 	</div>
 	</div>
 
 

+ 6 - 1
public/app/plugins/panel/table/editor.html

@@ -103,6 +103,11 @@
 						<metric-segment-model property="style.dateFormat" options="editor.dateFormats" on-change="editor.render()" custom="true"></metric-segment-model>
 						<metric-segment-model property="style.dateFormat" options="editor.dateFormats" on-change="editor.render()" custom="true"></metric-segment-model>
 					</li>
 					</li>
 				</ul>
 				</ul>
+				<ul class="tight-form-list" ng-if="style.type === 'string'">
+					<li class="tight-form-item">
+						<editor-checkbox text="Sanitize HTML" model="style.sanitize" change="editor.render()"></editor-checkbox>
+					</li>
+				</ul>
 				<div class="clearfix"></div>
 				<div class="clearfix"></div>
 			</div>
 			</div>
 			<div class="tight-form" ng-if="style.type === 'number'">
 			<div class="tight-form" ng-if="style.type === 'number'">
@@ -152,7 +157,7 @@
 						Decimals
 						Decimals
 					</li>
 					</li>
 					<li style="width: 105px">
 					<li style="width: 105px">
-						<input type="number" class="input-mini tight-form-input" ng-model="style.decimals" ng-change="render()" ng-model-onblur>
+						<input type="number" class="input-mini tight-form-input" ng-model="style.decimals" ng-change="editor.render()" ng-model-onblur>
 					</li>
 					</li>
 				</ul>
 				</ul>
 				<div class="clearfix"></div>
 				<div class="clearfix"></div>

+ 2 - 2
public/app/plugins/panel/table/module.ts

@@ -45,7 +45,7 @@ class TablePanelCtrl extends MetricsPanelCtrl {
   };
   };
 
 
   /** @ngInject */
   /** @ngInject */
-  constructor($scope, $injector, private annotationsSrv) {
+  constructor($scope, $injector, private annotationsSrv, private $sanitize) {
     super($scope, $injector);
     super($scope, $injector);
     this.pageIndex = 0;
     this.pageIndex = 0;
 
 
@@ -160,7 +160,7 @@ class TablePanelCtrl extends MetricsPanelCtrl {
     }
     }
 
 
     function appendTableRows(tbodyElem) {
     function appendTableRows(tbodyElem) {
-      var renderer = new TableRenderer(panel, data, ctrl.dashboard.isTimezoneUtc());
+      var renderer = new TableRenderer(panel, data, ctrl.dashboard.isTimezoneUtc(), ctrl.$sanitize);
       tbodyElem.empty();
       tbodyElem.empty();
       tbodyElem.html(renderer.render(ctrl.pageIndex));
       tbodyElem.html(renderer.render(ctrl.pageIndex));
     }
     }

+ 11 - 6
public/app/plugins/panel/table/renderer.ts

@@ -8,7 +8,7 @@ export class TableRenderer {
   formaters: any[];
   formaters: any[];
   colorState: any;
   colorState: any;
 
 
-  constructor(private panel, private table, private isUtc) {
+  constructor(private panel, private table, private isUtc, private sanitize) {
     this.formaters = [];
     this.formaters = [];
     this.colorState = {};
     this.colorState = {};
   }
   }
@@ -24,7 +24,7 @@ export class TableRenderer {
     return _.first(style.colors);
     return _.first(style.colors);
   }
   }
 
 
-  defaultCellFormater(v) {
+  defaultCellFormater(v, style) {
     if (v === null || v === void 0 || v === undefined) {
     if (v === null || v === void 0 || v === undefined) {
       return '';
       return '';
     }
     }
@@ -33,7 +33,11 @@ export class TableRenderer {
       v = v.join(', ');
       v = v.join(', ');
     }
     }
 
 
-    return v;
+    if (style && style.sanitize) {
+      return this.sanitize(v);
+    } else {
+      return _.escape(v);
+    }
   }
   }
 
 
   createColumnFormater(style, column) {
   createColumnFormater(style, column) {
@@ -61,7 +65,7 @@ export class TableRenderer {
         }
         }
 
 
         if (_.isString(v)) {
         if (_.isString(v)) {
-          return v;
+          return this.defaultCellFormater(v, style);
         }
         }
 
 
         if (style.colorMode) {
         if (style.colorMode) {
@@ -72,7 +76,9 @@ export class TableRenderer {
       };
       };
     }
     }
 
 
-    return this.defaultCellFormater;
+    return (value) => {
+      return this.defaultCellFormater(value, style);
+    };
   }
   }
 
 
   formatColumnValue(colIndex, value) {
   formatColumnValue(colIndex, value) {
@@ -96,7 +102,6 @@ export class TableRenderer {
 
 
   renderCell(columnIndex, value, addWidthHack = false) {
   renderCell(columnIndex, value, addWidthHack = false) {
     value = this.formatColumnValue(columnIndex, value);
     value = this.formatColumnValue(columnIndex, value);
-    value = _.escape(value);
     var style = '';
     var style = '';
     if (this.colorState.cell) {
     if (this.colorState.cell) {
       style = ' style="background-color:' + this.colorState.cell + ';color: white"';
       style = ' style="background-color:' + this.colorState.cell + ';color: white"';

+ 16 - 1
public/app/plugins/panel/table/specs/renderer_specs.ts

@@ -13,6 +13,7 @@ describe('when rendering table', () => {
       {text: 'Undefined'},
       {text: 'Undefined'},
       {text: 'String'},
       {text: 'String'},
       {text: 'United', unit: 'bps'},
       {text: 'United', unit: 'bps'},
+      {text: 'Sanitized'},
     ];
     ];
 
 
     var panel = {
     var panel = {
@@ -47,11 +48,20 @@ describe('when rendering table', () => {
           type: 'number',
           type: 'number',
           unit: 'ms',
           unit: 'ms',
           decimals: 2,
           decimals: 2,
+        },
+        {
+          pattern: 'Sanitized',
+          type: 'string',
+          sanitize: true,
         }
         }
       ]
       ]
     };
     };
 
 
-    var renderer = new TableRenderer(panel, table, 'utc');
+    var sanitize = function(value) {
+      return 'sanitized';
+    };
+
+    var renderer = new TableRenderer(panel, table, 'utc', sanitize);
 
 
     it('time column should be formated', () => {
     it('time column should be formated', () => {
       var html = renderer.renderCell(0, 1388556366666);
       var html = renderer.renderCell(0, 1388556366666);
@@ -107,6 +117,11 @@ describe('when rendering table', () => {
       var html = renderer.renderCell(3, undefined);
       var html = renderer.renderCell(3, undefined);
       expect(html).to.be('<td></td>');
       expect(html).to.be('<td></td>');
     });
     });
+
+    it('sanitized value should render as', () => {
+      var html = renderer.renderCell(6, 'text <a href="http://google.com">link</a>');
+      expect(html).to.be('<td>sanitized</td>');
+    });
   });
   });
 });
 });
 
 

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
public/img/grafana_net_logo.svg


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

@@ -105,7 +105,7 @@
   }
   }
 
 
   .confirm-modal-text2 {
   .confirm-modal-text2 {
-    font-size: $font-size-h5;
+    font-size: $font-size-root;
     padding-top: $spacer;
     padding-top: $spacer;
   }
   }
 
 

+ 16 - 0
public/sass/components/_search.scss

@@ -101,8 +101,24 @@
 
 
 .search-button-row {
 .search-button-row {
   padding-top: 20px;
   padding-top: 20px;
+
   button, a {
   button, a {
     margin-right: 10px;
     margin-right: 10px;
   }
   }
+
+  .search-button-row-explore-link {
+    color: $text-muted;
+    font-size: $font-size-sm;
+    padding-right: 7rem;
+    background: url(../img/grafana_net_logo.svg);
+    background-size: 6.5rem 3rem;
+    background-repeat: no-repeat;
+    background-position: right;
+    position: relative;
+    top: 0.8rem;
+    &:hover {
+      color: $link-hover-color;
+    }
+  }
 }
 }
 
 

+ 20 - 25
public/sass/pages/_plugins.scss

@@ -63,28 +63,23 @@
   }
   }
 }
 }
 
 
-// .app-edit-logo-box {
-//   padding: 1.2rem;
-//   background: $panel-bg;
-//   text-align: center;
-//   img {
-//     max-width: 7rem;
-//   }
-//   margin-right: 2rem;
-// }
-//
-// .app-edit-links {
-//   list-style: none;
-//   margin: 0 0 0 2rem;
-//
-//   li {
-//     background: $panel-bg;
-//     margin-top: 4px;
-//     padding: 0.2rem 1rem;
-//   }
-// }
-//
-// .app-edit-description {
-//   font-style: italic;
-//   margin-bottom: 1.5rem;
-// }
+.get-more-plugins-link {
+  color: $text-muted;
+  font-size: $font-size-sm;
+  padding-right: 7rem;
+  background: url(../img/grafana_net_logo.svg);
+  background-size: 6.5rem 3rem;
+  background-repeat: no-repeat;
+  background-position: right;
+  position: relative;
+  top: 1.2rem;
+  &:hover {
+    color: $link-hover-color;
+  }
+}
+
+@include media-breakpoint-down(sm) {
+  .get-more-plugins-link {
+    display: none;
+  }
+}

+ 1 - 1
tests/test-app/dashboards/connections.json

@@ -8,7 +8,7 @@
   ],
   ],
 
 
   "title": "Nginx Connections",
   "title": "Nginx Connections",
-  "revision": "1.5",
+  "revision": 25,
   "schemaVersion": 11,
   "schemaVersion": 11,
   "tags": ["tag1", "tag2"],
   "tags": ["tag1", "tag2"],
   "number_array": [1,2,3,10.33],
   "number_array": [1,2,3,10.33],

+ 1 - 1
tests/test-app/dashboards/connections_result.json

@@ -1,5 +1,5 @@
 {
 {
-  "revision": "1.5",
+  "revision": 25,
   "tags": ["tag1", "tag2"],
   "tags": ["tag1", "tag2"],
   "boolean_false": false,
   "boolean_false": false,
   "boolean_true": true,
   "boolean_true": true,

+ 1 - 1
tests/test-app/dashboards/memory.json

@@ -1,5 +1,5 @@
 {
 {
   "title": "Nginx Memory",
   "title": "Nginx Memory",
-  "revision": "2.0",
+  "revision": 2,
   "schemaVersion": 11
   "schemaVersion": 11
 }
 }

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.