diff --git a/devenv/dev-dashboards/panel-library/panel-library.json b/devenv/dev-dashboards/panel-library/panel-library.json new file mode 100644 index 00000000000..caa684a6b39 --- /dev/null +++ b/devenv/dev-dashboards/panel-library/panel-library.json @@ -0,0 +1,176 @@ +{ + "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": 66, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 5, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.4.0-pre", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "csvWave": { + "timeStep": 60, + "valuesCSV": "0,0,2,2,1,1" + }, + "lines": 10, + "points": [], + "pulseWave": { + "offCount": 3, + "offValue": 1, + "onCount": 3, + "onValue": 2, + "timeStep": 60 + }, + "refId": "A", + "scenarioId": "csv_metric_values", + "stream": { + "bands": 1, + "noise": 2.2, + "speed": 250, + "spread": 3.5, + "type": "signal" + }, + "stringInput": "1,20,90,30,5,0" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "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 + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 3, + "libraryPanel": { + "uid": "MAnX2ifMk" + } + }, + { + "gridPos": { + "h": 16, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 2, + "libraryPanel": { + "uid": "g1sNpCaMz" + } + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Panel - Panel Library", + "uid": "imQX6j-Gz", + "version": 1 +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 44685636d85..5fecd0f8ccf 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -304,10 +304,10 @@ func (hs *HTTPServer) registerRoutes() { // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) { dashboardRoute.Get("/uid/:uid", routing.Wrap(hs.GetDashboard)) - dashboardRoute.Delete("/uid/:uid", routing.Wrap(DeleteDashboardByUID)) + dashboardRoute.Delete("/uid/:uid", routing.Wrap(hs.DeleteDashboardByUID)) dashboardRoute.Get("/db/:slug", routing.Wrap(hs.GetDashboard)) - dashboardRoute.Delete("/db/:slug", routing.Wrap(DeleteDashboardBySlug)) + dashboardRoute.Delete("/db/:slug", routing.Wrap(hs.DeleteDashboardBySlug)) dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), routing.Wrap(CalculateDashboardDiff)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index d9b2f5ec676..c7ef7ca995e 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -147,6 +147,14 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { // make sure db version is in sync with json model version dash.Data.Set("version", dash.Version) + if hs.Cfg.IsPanelLibraryEnabled() { + // load library panels JSON for this dashboard + err = hs.LibraryPanelService.LoadLibraryPanelsForDashboard(dash) + if err != nil { + return response.Error(500, "Error while loading library panels", err) + } + } + dto := dtos.DashboardFullWithMeta{ Dashboard: dash.Data, Meta: meta, @@ -181,7 +189,7 @@ func getDashboardHelper(orgID int64, slug string, id int64, uid string) (*models return query.Result, nil } -func DeleteDashboardBySlug(c *models.ReqContext) response.Response { +func (hs *HTTPServer) DeleteDashboardBySlug(c *models.ReqContext) response.Response { query := models.GetDashboardsBySlugQuery{OrgId: c.OrgId, Slug: c.Params(":slug")} if err := bus.Dispatch(&query); err != nil { @@ -192,14 +200,14 @@ func DeleteDashboardBySlug(c *models.ReqContext) response.Response { return response.JSON(412, util.DynMap{"status": "multiple-slugs-exists", "message": models.ErrDashboardsWithSameSlugExists.Error()}) } - return deleteDashboard(c) + return hs.deleteDashboard(c) } -func DeleteDashboardByUID(c *models.ReqContext) response.Response { - return deleteDashboard(c) +func (hs *HTTPServer) DeleteDashboardByUID(c *models.ReqContext) response.Response { + return hs.deleteDashboard(c) } -func deleteDashboard(c *models.ReqContext) response.Response { +func (hs *HTTPServer) deleteDashboard(c *models.ReqContext) response.Response { dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, c.Params(":uid")) if rsp != nil { return rsp @@ -210,6 +218,14 @@ func deleteDashboard(c *models.ReqContext) response.Response { return dashboardGuardianResponse(err) } + if hs.Cfg.IsPanelLibraryEnabled() { + // disconnect all library panels for this dashboard + err := hs.LibraryPanelService.DisconnectLibraryPanelsForDashboard(dash) + if err != nil { + hs.log.Error("Failed to disconnect library panels", "dashboard", dash.Id, "user", c.SignedInUser.UserId, "error", err) + } + } + err := dashboards.NewService().DeleteDashboard(dash.Id, c.OrgId) if err != nil { var dashboardErr models.DashboardErr @@ -256,6 +272,14 @@ func (hs *HTTPServer) PostDashboard(c *models.ReqContext, cmd models.SaveDashboa allowUiUpdate = hs.ProvisioningService.GetAllowUIUpdatesFromConfig(provisioningData.Name) } + if hs.Cfg.IsPanelLibraryEnabled() { + // clean up all unnecessary library panels JSON properties so we store a minimum JSON + err = hs.LibraryPanelService.CleanLibraryPanelsForDashboard(dash) + if err != nil { + return response.Error(500, "Error while cleaning library panels", err) + } + } + dashItem := &dashboards.SaveDashboardDTO{ Dashboard: dash, Message: cmd.Message, @@ -288,6 +312,14 @@ func (hs *HTTPServer) PostDashboard(c *models.ReqContext, cmd models.SaveDashboa } } + if hs.Cfg.IsPanelLibraryEnabled() { + // connect library panels for this dashboard after the dashboard is stored and has an ID + err = hs.LibraryPanelService.ConnectLibraryPanelsForDashboard(c, dashboard) + if err != nil { + return response.Error(500, "Error while connecting library panels", err) + } + } + c.TimeRequest(metrics.MApiDashboardSave) return response.JSON(200, util.DynMap{ "status": "success", diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 45d5d1b5cec..f133e04807a 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -165,7 +165,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { state := setUp() - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, &HTTPServer{Cfg: setting.NewCfg()}) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "child-dash", state.dashQueries[0].Slug) @@ -175,7 +175,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUp() - CallDeleteDashboardByUID(sc) + CallDeleteDashboardByUID(sc, &HTTPServer{Cfg: setting.NewCfg()}) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) @@ -230,7 +230,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { state := setUp() - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, &HTTPServer{Cfg: setting.NewCfg()}) assert.Equal(t, 200, sc.resp.Code) assert.Equal(t, "child-dash", state.dashQueries[0].Slug) }) @@ -239,7 +239,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUp() - CallDeleteDashboardByUID(sc) + CallDeleteDashboardByUID(sc, &HTTPServer{Cfg: setting.NewCfg()}) assert.Equal(t, 200, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) }) @@ -354,7 +354,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { state := setUp() - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "child-dash", state.dashQueries[0].Slug) }) @@ -363,7 +363,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUp() - CallDeleteDashboardByUID(sc) + CallDeleteDashboardByUID(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) }) @@ -414,7 +414,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { state := setUp() - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "child-dash", state.dashQueries[0].Slug) }) @@ -423,7 +423,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUp() - CallDeleteDashboardByUID(sc) + CallDeleteDashboardByUID(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) }) @@ -492,7 +492,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { state := setUpInner() - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, hs) assert.Equal(t, 200, sc.resp.Code) assert.Equal(t, "child-dash", state.dashQueries[0].Slug) }) @@ -500,7 +500,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() - CallDeleteDashboardByUID(sc) + CallDeleteDashboardByUID(sc, hs) assert.Equal(t, 200, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) }) @@ -570,7 +570,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { state := setUpInner() - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "child-dash", state.dashQueries[0].Slug) }) @@ -578,7 +578,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() - CallDeleteDashboardByUID(sc) + CallDeleteDashboardByUID(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) }) @@ -624,7 +624,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { state := setUpInner() - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, hs) assert.Equal(t, 200, sc.resp.Code) assert.Equal(t, "child-dash", state.dashQueries[0].Slug) }) @@ -632,7 +632,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() - CallDeleteDashboardByUID(sc) + CallDeleteDashboardByUID(sc, hs) assert.Equal(t, 200, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) }) @@ -690,7 +690,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { state := setUpInner() - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "child-dash", state.dashQueries[0].Slug) }) @@ -698,7 +698,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) { state := setUpInner() - CallDeleteDashboardByUID(sc) + CallDeleteDashboardByUID(sc, hs) assert.Equal(t, 403, sc.resp.Code) assert.Equal(t, "abcdefghi", state.dashQueries[0].Uid) }) @@ -744,7 +744,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { role := models.ROLE_EDITOR loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/db/dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) { - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, &HTTPServer{Cfg: setting.NewCfg()}) assert.Equal(t, 412, sc.resp.Code) result := sc.ToJSON() @@ -1033,7 +1033,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/db/dash", "/api/dashboards/db/:slug", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() - CallDeleteDashboardBySlug(sc) + CallDeleteDashboardBySlug(sc, &HTTPServer{Cfg: setting.NewCfg()}) assert.Equal(t, 400, sc.resp.Code) result := sc.ToJSON() @@ -1043,7 +1043,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { loggedInUserScenarioWithRole(t, "When calling DELETE on", "DELETE", "/api/dashboards/db/abcdefghi", "/api/dashboards/db/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) { setUp() - CallDeleteDashboardByUID(sc) + CallDeleteDashboardByUID(sc, &HTTPServer{Cfg: setting.NewCfg()}) assert.Equal(t, 400, sc.resp.Code) result := sc.ToJSON() @@ -1141,21 +1141,21 @@ func callGetDashboardVersions(sc *scenarioContext) { sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } -func CallDeleteDashboardBySlug(sc *scenarioContext) { +func CallDeleteDashboardBySlug(sc *scenarioContext, hs *HTTPServer) { bus.AddHandler("test", func(cmd *models.DeleteDashboardCommand) error { return nil }) - sc.handlerFunc = DeleteDashboardBySlug + sc.handlerFunc = hs.DeleteDashboardBySlug sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() } -func CallDeleteDashboardByUID(sc *scenarioContext) { +func CallDeleteDashboardByUID(sc *scenarioContext, hs *HTTPServer) { bus.AddHandler("test", func(cmd *models.DeleteDashboardCommand) error { return nil }) - sc.handlerFunc = DeleteDashboardByUID + sc.handlerFunc = hs.DeleteDashboardByUID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index df90b494599..f2f0ab53c60 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -33,6 +33,7 @@ import ( "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/hooks" + "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/quota" @@ -59,26 +60,27 @@ type HTTPServer struct { httpSrv *http.Server middlewares []macaron.Handler - RouteRegister routing.RouteRegister `inject:""` - Bus bus.Bus `inject:""` - RenderService rendering.Service `inject:""` - Cfg *setting.Cfg `inject:""` - HooksService *hooks.HooksService `inject:""` - CacheService *localcache.CacheService `inject:""` - DatasourceCache datasources.CacheService `inject:""` - AuthTokenService models.UserTokenService `inject:""` - QuotaService *quota.QuotaService `inject:""` - RemoteCacheService *remotecache.RemoteCache `inject:""` - ProvisioningService provisioning.ProvisioningService `inject:""` - Login *login.LoginService `inject:""` - License models.Licensing `inject:""` - BackendPluginManager backendplugin.Manager `inject:""` - PluginManager *plugins.PluginManager `inject:""` - SearchService *search.SearchService `inject:""` - ShortURLService *shorturls.ShortURLService `inject:""` - Live *live.GrafanaLive `inject:""` - ContextHandler *contexthandler.ContextHandler `inject:""` - SQLStore *sqlstore.SQLStore `inject:""` + RouteRegister routing.RouteRegister `inject:""` + Bus bus.Bus `inject:""` + RenderService rendering.Service `inject:""` + Cfg *setting.Cfg `inject:""` + HooksService *hooks.HooksService `inject:""` + CacheService *localcache.CacheService `inject:""` + DatasourceCache datasources.CacheService `inject:""` + AuthTokenService models.UserTokenService `inject:""` + QuotaService *quota.QuotaService `inject:""` + RemoteCacheService *remotecache.RemoteCache `inject:""` + ProvisioningService provisioning.ProvisioningService `inject:""` + Login *login.LoginService `inject:""` + License models.Licensing `inject:""` + BackendPluginManager backendplugin.Manager `inject:""` + PluginManager *plugins.PluginManager `inject:""` + SearchService *search.SearchService `inject:""` + ShortURLService *shorturls.ShortURLService `inject:""` + Live *live.GrafanaLive `inject:""` + ContextHandler *contexthandler.ContextHandler `inject:""` + SQLStore *sqlstore.SQLStore `inject:""` + LibraryPanelService *librarypanels.LibraryPanelService `inject:""` Listener net.Listener } diff --git a/pkg/components/simplejson/simplejson.go b/pkg/components/simplejson/simplejson.go index 5adc04d210b..177a4434a56 100644 --- a/pkg/components/simplejson/simplejson.go +++ b/pkg/components/simplejson/simplejson.go @@ -181,6 +181,17 @@ func (j *Json) GetIndex(index int) *Json { return &Json{nil} } +// SetIndex modifies `Json` array by `index` and `value` +// for `index` in its `array` representation +func (j *Json) SetIndex(index int, val interface{}) { + a, err := j.Array() + if err == nil { + if len(a) > index { + a[index] = val + } + } +} + // CheckGet returns a pointer to a new `Json` object and // a `bool` identifying success or failure // diff --git a/pkg/services/librarypanels/database.go b/pkg/services/librarypanels/database.go index d0c0f5a51a8..89fdf277e13 100644 --- a/pkg/services/librarypanels/database.go +++ b/pkg/services/librarypanels/database.go @@ -5,6 +5,8 @@ import ( "fmt" "time" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/models" @@ -40,27 +42,46 @@ func (lps *LibraryPanelService) createLibraryPanel(c *models.ReqContext, cmd cre return libraryPanel, err } +func connectDashboard(session *sqlstore.DBSession, dialect migrator.Dialect, user *models.SignedInUser, uid string, dashboardID int64) error { + panel, err := getLibraryPanel(session, uid, user.OrgId) + if err != nil { + return err + } + + // TODO add check that dashboard exists + + libraryPanelDashboard := libraryPanelDashboard{ + DashboardID: dashboardID, + LibraryPanelID: panel.ID, + Created: time.Now(), + CreatedBy: user.UserId, + } + if _, err := session.Insert(&libraryPanelDashboard); err != nil { + if dialect.IsUniqueConstraintViolation(err) { + return nil + } + return err + } + return nil +} + // connectDashboard adds a connection between a Library Panel and a Dashboard. func (lps *LibraryPanelService) connectDashboard(c *models.ReqContext, uid string, dashboardID int64) error { err := lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { - panel, err := getLibraryPanel(session, uid, c.SignedInUser.OrgId) - if err != nil { - return err - } + return connectDashboard(session, lps.SQLStore.Dialect, c.SignedInUser, uid, dashboardID) + }) - // TODO add check that dashboard exists + return err +} - libraryPanelDashboard := libraryPanelDashboard{ - DashboardID: dashboardID, - LibraryPanelID: panel.ID, - Created: time.Now(), - CreatedBy: c.SignedInUser.UserId, - } - if _, err := session.Insert(&libraryPanelDashboard); err != nil { - if lps.SQLStore.Dialect.IsUniqueConstraintViolation(err) { - return nil +// connectLibraryPanelsForDashboard adds connections for all Library Panels in a Dashboard. +func (lps *LibraryPanelService) connectLibraryPanelsForDashboard(c *models.ReqContext, uids []string, dashboardID int64) error { + err := lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { + for _, uid := range uids { + err := connectDashboard(session, lps.SQLStore.Dialect, c.SignedInUser, uid, dashboardID) + if err != nil { + return err } - return err } return nil }) @@ -110,6 +131,23 @@ func (lps *LibraryPanelService) disconnectDashboard(c *models.ReqContext, uid st }) } +// disconnectLibraryPanelsForDashboard deletes connections for all Library Panels in a Dashboard. +func (lps *LibraryPanelService) disconnectLibraryPanelsForDashboard(dashboardID int64, panelCount int64) error { + return lps.SQLStore.WithTransactionalDbSession(context.Background(), func(session *sqlstore.DBSession) error { + result, err := session.Exec("DELETE FROM library_panel_dashboard WHERE dashboard_id=?", dashboardID) + if err != nil { + return err + } + if rowsAffected, err := result.RowsAffected(); err != nil { + return err + } else if rowsAffected != panelCount { + lps.log.Warn("Number of disconnects does not match number of panels", "dashboard", dashboardID, "rowsAffected", rowsAffected, "panelCount", panelCount) + } + + return nil + }) +} + func getLibraryPanel(session *sqlstore.DBSession, uid string, orgID int64) (LibraryPanel, error) { libraryPanels := make([]LibraryPanel, 0) session.Table("library_panel") @@ -183,6 +221,33 @@ func (lps *LibraryPanelService) getConnectedDashboards(c *models.ReqContext, uid return connectedDashboardIDs, err } +func (lps *LibraryPanelService) getLibraryPanelsForDashboardID(dashboardID int64) (map[string]LibraryPanel, error) { + libraryPanelMap := make(map[string]LibraryPanel) + err := lps.SQLStore.WithDbSession(context.Background(), func(session *sqlstore.DBSession) error { + sql := `SELECT + lp.id, lp.org_id, lp.folder_id, lp.uid, lp.name, lp.model, lp.created, lp.created_by, lp.updated, updated_by + FROM + library_panel_dashboard AS lpd + INNER JOIN + library_panel AS lp ON lpd.librarypanel_id = lp.id AND lpd.dashboard_id=?` + + var libraryPanels []LibraryPanel + sess := session.SQL(sql, dashboardID) + err := sess.Find(&libraryPanels) + if err != nil { + return err + } + + for _, panel := range libraryPanels { + libraryPanelMap[panel.UID] = panel + } + + return nil + }) + + return libraryPanelMap, err +} + // patchLibraryPanel updates a Library Panel. func (lps *LibraryPanelService) patchLibraryPanel(c *models.ReqContext, cmd patchLibraryPanelCommand, uid string) (LibraryPanel, error) { var libraryPanel LibraryPanel diff --git a/pkg/services/librarypanels/librarypanels.go b/pkg/services/librarypanels/librarypanels.go index 693d6a1410d..171a5e51872 100644 --- a/pkg/services/librarypanels/librarypanels.go +++ b/pkg/services/librarypanels/librarypanels.go @@ -1,8 +1,12 @@ package librarypanels import ( + "fmt" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -39,6 +43,157 @@ func (lps *LibraryPanelService) IsEnabled() bool { return lps.Cfg.IsPanelLibraryEnabled() } +// LoadLibraryPanelsForDashboard loops through all panels in dashboard JSON and replaces any library panel JSON +// with JSON stored for library panel in db. +func (lps *LibraryPanelService) LoadLibraryPanelsForDashboard(dash *models.Dashboard) error { + if !lps.IsEnabled() { + return nil + } + + libraryPanels, err := lps.getLibraryPanelsForDashboardID(dash.Id) + if err != nil { + return err + } + + panels := dash.Data.Get("panels").MustArray() + for i, panel := range panels { + panelAsJSON := simplejson.NewFromAny(panel) + libraryPanel := panelAsJSON.Get("libraryPanel") + if libraryPanel.Interface() == nil { + continue + } + + // we have a library panel + uid := libraryPanel.Get("uid").MustString() + if len(uid) == 0 { + return errLibraryPanelHeaderUIDMissing + } + + libraryPanelInDB, ok := libraryPanels[uid] + if !ok { + return fmt.Errorf("found connection to library panel %q that isn't in database", uid) + } + + // we have a match between what is stored in db and in dashboard json + libraryPanelModel, err := libraryPanelInDB.Model.MarshalJSON() + if err != nil { + return fmt.Errorf("could not marshal library panel JSON: %w", err) + } + + libraryPanelModelAsJSON, err := simplejson.NewJson(libraryPanelModel) + if err != nil { + return fmt.Errorf("could not convert library panel to simplejson model: %w", err) + } + + // set the library panel json as the new panel json in dashboard json + dash.Data.Get("panels").SetIndex(i, libraryPanelModelAsJSON.Interface()) + + // set dashboard specific props + elem := dash.Data.Get("panels").GetIndex(i) + elem.Set("gridPos", panelAsJSON.Get("gridPos").MustMap()) + elem.Set("id", panelAsJSON.Get("id").MustInt64()) + elem.Set("libraryPanel", map[string]interface{}{ + "uid": libraryPanelInDB.UID, + "name": libraryPanelInDB.Name, + }) + } + + return nil +} + +// CleanLibraryPanelsForDashboard loops through all panels in dashboard JSON and cleans up any library panel JSON so that +// only the necessary JSON properties remain when storing the dashboard JSON. +func (lps *LibraryPanelService) CleanLibraryPanelsForDashboard(dash *models.Dashboard) error { + if !lps.IsEnabled() { + return nil + } + + panels := dash.Data.Get("panels").MustArray() + for i, panel := range panels { + panelAsJSON := simplejson.NewFromAny(panel) + libraryPanel := panelAsJSON.Get("libraryPanel") + if libraryPanel.Interface() == nil { + continue + } + + // we have a library panel + uid := libraryPanel.Get("uid").MustString() + if len(uid) == 0 { + return errLibraryPanelHeaderUIDMissing + } + name := libraryPanel.Get("name").MustString() + if len(name) == 0 { + return errLibraryPanelHeaderNameMissing + } + + // keep only the necessary JSON properties, the rest of the properties should be safely stored in library_panels table + gridPos := panelAsJSON.Get("gridPos").MustMap() + id := panelAsJSON.Get("id").MustInt64(int64(i)) + dash.Data.Get("panels").SetIndex(i, map[string]interface{}{ + "id": id, + "gridPos": gridPos, + "libraryPanel": map[string]interface{}{ + "uid": uid, + "name": name, + }, + }) + } + + return nil +} + +// ConnectLibraryPanelsForDashboard loops through all panels in dashboard JSON and connects any library panels to the dashboard. +func (lps *LibraryPanelService) ConnectLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard) error { + if !lps.IsEnabled() { + return nil + } + + panels := dash.Data.Get("panels").MustArray() + var libraryPanels []string + for _, panel := range panels { + panelAsJSON := simplejson.NewFromAny(panel) + libraryPanel := panelAsJSON.Get("libraryPanel") + if libraryPanel.Interface() == nil { + continue + } + + // we have a library panel + uid := libraryPanel.Get("uid").MustString() + if len(uid) == 0 { + return errLibraryPanelHeaderUIDMissing + } + libraryPanels = append(libraryPanels, uid) + } + + return lps.connectLibraryPanelsForDashboard(c, libraryPanels, dash.Id) +} + +// DisconnectLibraryPanelsForDashboard loops through all panels in dashboard JSON and disconnects any library panels from the dashboard. +func (lps *LibraryPanelService) DisconnectLibraryPanelsForDashboard(dash *models.Dashboard) error { + if !lps.IsEnabled() { + return nil + } + + panels := dash.Data.Get("panels").MustArray() + panelCount := int64(0) + for _, panel := range panels { + panelAsJSON := simplejson.NewFromAny(panel) + libraryPanel := panelAsJSON.Get("libraryPanel") + if libraryPanel.Interface() == nil { + continue + } + + // we have a library panel + uid := libraryPanel.Get("uid").MustString() + if len(uid) == 0 { + return errLibraryPanelHeaderUIDMissing + } + panelCount++ + } + + return lps.disconnectLibraryPanelsForDashboard(dash.Id, panelCount) +} + // AddMigration defines database migrations. // If Panel Library is not enabled does nothing. func (lps *LibraryPanelService) AddMigration(mg *migrator.Migrator) { diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index de46be833dd..867a2296255 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -2,9 +2,12 @@ package librarypanels import ( "encoding/json" + "fmt" "testing" "time" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" "gopkg.in/macaron.v1" @@ -30,7 +33,7 @@ func TestCreateLibraryPanel(t *testing.T) { func TestConnectLibraryPanel(t *testing.T) { testScenario(t, "When an admin tries to create a connection for a library panel that does not exist, it should fail", func(t *testing.T, sc scenarioContext) { - sc.reqContext.ReplaceAllParams(map[string]string{":uid": "unknown", "dashboardId": "1"}) + sc.reqContext.ReplaceAllParams(map[string]string{":uid": "unknown", ":dashboardId": "1"}) response := sc.service.connectHandler(sc.reqContext) require.Equal(t, 404, response.Status()) }) @@ -45,7 +48,7 @@ func TestConnectLibraryPanel(t *testing.T) { err := json.Unmarshal(response.Body(), &result) require.NoError(t, err) - sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID, "dashboardId": "1"}) + sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID, ":dashboardId": "1"}) response = sc.service.connectHandler(sc.reqContext) require.Equal(t, 200, response.Status()) @@ -97,7 +100,7 @@ func TestDeleteLibraryPanel(t *testing.T) { func TestDisconnectLibraryPanel(t *testing.T) { testScenario(t, "When an admin tries to remove a connection with a library panel that does not exist, it should fail", func(t *testing.T, sc scenarioContext) { - sc.reqContext.ReplaceAllParams(map[string]string{":uid": "unknown", "dashboardId": "1"}) + sc.reqContext.ReplaceAllParams(map[string]string{":uid": "unknown", ":dashboardId": "1"}) response := sc.service.disconnectHandler(sc.reqContext) require.Equal(t, 404, response.Status()) }) @@ -112,7 +115,7 @@ func TestDisconnectLibraryPanel(t *testing.T) { err := json.Unmarshal(response.Body(), &result) require.NoError(t, err) - sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID, "dashboardId": "1"}) + sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID, ":dashboardId": "1"}) response = sc.service.disconnectHandler(sc.reqContext) require.Equal(t, 404, response.Status()) }) @@ -127,7 +130,7 @@ func TestDisconnectLibraryPanel(t *testing.T) { err := json.Unmarshal(response.Body(), &result) require.NoError(t, err) - sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID, "dashboardId": "1"}) + sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID, ":dashboardId": "1"}) response = sc.service.connectHandler(sc.reqContext) require.Equal(t, 200, response.Status()) response = sc.service.disconnectHandler(sc.reqContext) @@ -330,7 +333,7 @@ func TestPatchLibraryPanel(t *testing.T) { { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, - "name": "Model - New name", + "title": "Model - New name", "type": "text" } `), @@ -344,7 +347,7 @@ func TestPatchLibraryPanel(t *testing.T) { require.NoError(t, err) existing.Result.FolderID = int64(2) existing.Result.Name = "Panel - New name" - existing.Result.Model["name"] = "Model - New name" + existing.Result.Model["title"] = "Model - New name" if diff := cmp.Diff(existing.Result, result.Result, getCompareOptions()...); diff != "" { t.Fatalf("Result mismatch (-want +got):\n%s", diff) } @@ -516,6 +519,578 @@ func TestPatchLibraryPanel(t *testing.T) { }) } +func TestLoadLibraryPanelsForDashboard(t *testing.T) { + testScenario(t, "When an admin tries to load a dashboard with a library panel, it should copy JSON properties from library panel", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID, ":dashboardId": "1"}) + response = sc.service.connectHandler(sc.reqContext) + require.Equal(t, 200, response.Status()) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "libraryPanel": map[string]interface{}{ + "uid": existing.Result.UID, + "name": existing.Result.Name, + }, + }, + }, + } + dash := models.Dashboard{ + Id: 1, + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.LoadLibraryPanelsForDashboard(&dash) + require.NoError(t, err) + expectedJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "datasource": "${DS_GDEV-TESTDATA}", + "libraryPanel": map[string]interface{}{ + "uid": existing.Result.UID, + "name": existing.Result.Name, + }, + "title": "Text - Library Panel", + "type": "text", + }, + }, + } + expected := simplejson.NewFromAny(expectedJSON) + if diff := cmp.Diff(expected.Interface(), dash.Data.Interface(), getCompareOptions()...); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) + + testScenario(t, "When an admin tries to load a dashboard with a library panel without uid, it should fail", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID, ":dashboardId": "1"}) + response = sc.service.connectHandler(sc.reqContext) + require.Equal(t, 200, response.Status()) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "libraryPanel": map[string]interface{}{ + "name": existing.Result.Name, + }, + }, + }, + } + dash := models.Dashboard{ + Id: 1, + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.LoadLibraryPanelsForDashboard(&dash) + require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) + }) + + testScenario(t, "When an admin tries to load a dashboard with a library panel that is not connected, it should fail", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "libraryPanel": map[string]interface{}{ + "uid": existing.Result.UID, + "name": existing.Result.Name, + }, + }, + }, + } + dash := models.Dashboard{ + Id: 1, + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.LoadLibraryPanelsForDashboard(&dash) + require.EqualError(t, err, fmt.Errorf("found connection to library panel %q that isn't in database", existing.Result.UID).Error()) + }) +} + +func TestCleanLibraryPanelsForDashboard(t *testing.T) { + testScenario(t, "When an admin tries to store a dashboard with a library panel, it should just keep the correct JSON properties in library panel", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "datasource": "${DS_GDEV-TESTDATA}", + "libraryPanel": map[string]interface{}{ + "uid": existing.Result.UID, + "name": existing.Result.Name, + }, + "title": "Text - Library Panel", + "type": "text", + }, + }, + } + dash := models.Dashboard{ + Id: 1, + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.CleanLibraryPanelsForDashboard(&dash) + require.NoError(t, err) + expectedJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "libraryPanel": map[string]interface{}{ + "uid": existing.Result.UID, + "name": existing.Result.Name, + }, + }, + }, + } + expected := simplejson.NewFromAny(expectedJSON) + if diff := cmp.Diff(expected.Interface(), dash.Data.Interface(), getCompareOptions()...); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + }) + + testScenario(t, "When an admin tries to store a dashboard with a library panel without uid, it should fail", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "datasource": "${DS_GDEV-TESTDATA}", + "libraryPanel": map[string]interface{}{ + "name": existing.Result.Name, + }, + "title": "Text - Library Panel", + "type": "text", + }, + }, + } + dash := models.Dashboard{ + Id: 1, + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.CleanLibraryPanelsForDashboard(&dash) + require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) + }) + + testScenario(t, "When an admin tries to store a dashboard with a library panel without name, it should fail", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "datasource": "${DS_GDEV-TESTDATA}", + "libraryPanel": map[string]interface{}{ + "uid": existing.Result.UID, + }, + "title": "Text - Library Panel", + "type": "text", + }, + }, + } + dash := models.Dashboard{ + Id: 1, + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.CleanLibraryPanelsForDashboard(&dash) + require.EqualError(t, err, errLibraryPanelHeaderNameMissing.Error()) + }) +} + +func TestConnectLibraryPanelsForDashboard(t *testing.T) { + testScenario(t, "When an admin tries to store a dashboard with a library panel, it should connect the two", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "datasource": "${DS_GDEV-TESTDATA}", + "libraryPanel": map[string]interface{}{ + "uid": existing.Result.UID, + "name": existing.Result.Name, + }, + "title": "Text - Library Panel", + "type": "text", + }, + }, + } + dash := models.Dashboard{ + Id: int64(1), + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.ConnectLibraryPanelsForDashboard(sc.reqContext, &dash) + require.NoError(t, err) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID}) + response = sc.service.getConnectedDashboardsHandler(sc.reqContext) + require.Equal(t, 200, response.Status()) + + var dashResult libraryPanelDashboardsResult + err = json.Unmarshal(response.Body(), &dashResult) + require.NoError(t, err) + require.Len(t, dashResult.Result, 1) + require.Equal(t, int64(1), dashResult.Result[0]) + }) + + testScenario(t, "When an admin tries to store a dashboard with a library panel without uid, it should fail", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "datasource": "${DS_GDEV-TESTDATA}", + "libraryPanel": map[string]interface{}{ + "name": existing.Result.Name, + }, + "title": "Text - Library Panel", + "type": "text", + }, + }, + } + dash := models.Dashboard{ + Id: int64(1), + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.ConnectLibraryPanelsForDashboard(sc.reqContext, &dash) + require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) + }) +} + +func TestDisconnectLibraryPanelsForDashboard(t *testing.T) { + testScenario(t, "When an admin tries to delete a dashboard with a library panel, it should disconnect the two", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID, ":dashboardId": "1"}) + response = sc.service.connectHandler(sc.reqContext) + require.Equal(t, 200, response.Status()) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "datasource": "${DS_GDEV-TESTDATA}", + "libraryPanel": map[string]interface{}{ + "uid": existing.Result.UID, + "name": existing.Result.Name, + }, + "title": "Text - Library Panel", + "type": "text", + }, + }, + } + dash := models.Dashboard{ + Id: int64(1), + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.DisconnectLibraryPanelsForDashboard(&dash) + require.NoError(t, err) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID}) + response = sc.service.getConnectedDashboardsHandler(sc.reqContext) + require.Equal(t, 200, response.Status()) + + var dashResult libraryPanelDashboardsResult + err = json.Unmarshal(response.Body(), &dashResult) + require.NoError(t, err) + require.Empty(t, dashResult.Result) + }) + + testScenario(t, "When an admin tries to delete a dashboard with a library panel without uid, it should fail", + func(t *testing.T, sc scenarioContext) { + command := getCreateCommand(1, "Text - Library Panel1") + response := sc.service.createHandler(sc.reqContext, command) + require.Equal(t, 200, response.Status()) + + var existing libraryPanelResult + err := json.Unmarshal(response.Body(), &existing) + require.NoError(t, err) + + sc.reqContext.ReplaceAllParams(map[string]string{":uid": existing.Result.UID, ":dashboardId": "1"}) + response = sc.service.connectHandler(sc.reqContext) + require.Equal(t, 200, response.Status()) + + dashJSON := map[string]interface{}{ + "panels": []interface{}{ + map[string]interface{}{ + "id": int64(1), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 0, + "y": 0, + }, + }, + map[string]interface{}{ + "id": int64(2), + "gridPos": map[string]interface{}{ + "h": 6, + "w": 6, + "x": 6, + "y": 0, + }, + "datasource": "${DS_GDEV-TESTDATA}", + "libraryPanel": map[string]interface{}{ + "name": existing.Result.Name, + }, + "title": "Text - Library Panel", + "type": "text", + }, + }, + } + dash := models.Dashboard{ + Id: int64(1), + Data: simplejson.NewFromAny(dashJSON), + } + + err = sc.service.DisconnectLibraryPanelsForDashboard(&dash) + require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) + }) +} + type libraryPanel struct { ID int64 `json:"id"` OrgID int64 `json:"orgId"` @@ -570,7 +1145,7 @@ func getCreateCommand(folderID int64, name string) createLibraryPanelCommand { { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, - "name": "Text - Library Panel", + "title": "Text - Library Panel", "type": "text" } `), diff --git a/pkg/services/librarypanels/models.go b/pkg/services/librarypanels/models.go index 79289ff0311..1482db8ba8d 100644 --- a/pkg/services/librarypanels/models.go +++ b/pkg/services/librarypanels/models.go @@ -40,6 +40,10 @@ var ( errLibraryPanelNotFound = errors.New("library panel could not be found") // errLibraryPanelDashboardNotFound is an error for when a library panel connection can't be found. errLibraryPanelDashboardNotFound = errors.New("library panel connection could not be found") + // errLibraryPanelHeaderUIDMissing is an error for when a library panel header is missing the uid property. + errLibraryPanelHeaderUIDMissing = errors.New("library panel header is missing required property uid") + // errLibraryPanelHeaderNameMissing is an error for when a library panel header is missing the name property. + errLibraryPanelHeaderNameMissing = errors.New("library panel header is missing required property name") ) // Commands