mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
LibraryPanels: Improves export and import of library panels between orgs (#39214)
* Chore: adds tests to reducer * Refactor: rewrite state * Refactor: adds library panels to export * wip * Refactor: adds import library panels * Refactor: changes UI * Chore: pushing drone * Update public/app/features/manage-dashboards/components/ImportDashboardForm.tsx Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * Update public/app/features/manage-dashboards/components/ImportDashboardForm.tsx Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * Chore: reverted unknown merge changes Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
This commit is contained in:
@@ -1245,6 +1245,10 @@ func (m *mockLibraryPanelService) ConnectLibraryPanelsForDashboard(c *models.Req
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockLibraryPanelService) ImportLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard, folderID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockLibraryElementService struct {
|
||||
}
|
||||
|
||||
@@ -1252,6 +1256,11 @@ func (l *mockLibraryElementService) CreateElement(c *models.ReqContext, cmd libr
|
||||
return libraryelements.LibraryElementDTO{}, nil
|
||||
}
|
||||
|
||||
// GetElement gets an element from a UID.
|
||||
func (l *mockLibraryElementService) GetElement(c *models.ReqContext, UID string) (libraryelements.LibraryElementDTO, error) {
|
||||
return libraryelements.LibraryElementDTO{}, nil
|
||||
}
|
||||
|
||||
// GetElementsForDashboard gets all connected elements for a specific dashboard.
|
||||
func (l *mockLibraryElementService) GetElementsForDashboard(c *models.ReqContext, dashboardID int64) (map[string]libraryelements.LibraryElementDTO, error) {
|
||||
return map[string]libraryelements.LibraryElementDTO{}, nil
|
||||
|
||||
@@ -224,6 +224,11 @@ func (hs *HTTPServer) ImportDashboard(c *models.ReqContext, apiCmd dtos.ImportDa
|
||||
return hs.dashboardSaveErrorToApiResponse(err)
|
||||
}
|
||||
|
||||
err = hs.LibraryPanelService.ImportLibraryPanelsForDashboard(c, dash, apiCmd.FolderId)
|
||||
if err != nil {
|
||||
return response.Error(500, "Error while importing library panels", err)
|
||||
}
|
||||
|
||||
err = hs.LibraryPanelService.ConnectLibraryPanelsForDashboard(c, dash)
|
||||
if err != nil {
|
||||
return response.Error(500, "Error while connecting library panels", err)
|
||||
|
||||
@@ -46,7 +46,7 @@ func (l *LibraryElementService) deleteHandler(c *models.ReqContext) response.Res
|
||||
|
||||
// getHandler handles GET /api/library-elements/:uid.
|
||||
func (l *LibraryElementService) getHandler(c *models.ReqContext) response.Response {
|
||||
element, err := l.getLibraryElementByUid(c)
|
||||
element, err := l.getLibraryElementByUid(c, macaron.Params(c.Req)[":uid"])
|
||||
if err != nil {
|
||||
return toLibraryElementError(err, "Failed to get library element")
|
||||
}
|
||||
@@ -108,8 +108,8 @@ func toLibraryElementError(err error, message string) response.Response {
|
||||
if errors.Is(err, errLibraryElementAlreadyExists) {
|
||||
return response.Error(400, errLibraryElementAlreadyExists.Error(), err)
|
||||
}
|
||||
if errors.Is(err, errLibraryElementNotFound) {
|
||||
return response.Error(404, errLibraryElementNotFound.Error(), err)
|
||||
if errors.Is(err, ErrLibraryElementNotFound) {
|
||||
return response.Error(404, ErrLibraryElementNotFound.Error(), err)
|
||||
}
|
||||
if errors.Is(err, errLibraryElementDashboardNotFound) {
|
||||
return response.Error(404, errLibraryElementDashboardNotFound.Error(), err)
|
||||
|
||||
@@ -81,7 +81,7 @@ func getLibraryElement(dialect migrator.Dialect, session *sqlstore.DBSession, ui
|
||||
return LibraryElementWithMeta{}, err
|
||||
}
|
||||
if len(elements) == 0 {
|
||||
return LibraryElementWithMeta{}, errLibraryElementNotFound
|
||||
return LibraryElementWithMeta{}, ErrLibraryElementNotFound
|
||||
}
|
||||
if len(elements) > 1 {
|
||||
return LibraryElementWithMeta{}, fmt.Errorf("found %d elements, while expecting at most one", len(elements))
|
||||
@@ -196,28 +196,28 @@ func (l *LibraryElementService) deleteLibraryElement(c *models.ReqContext, uid s
|
||||
if rowsAffected, err := result.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if rowsAffected != 1 {
|
||||
return errLibraryElementNotFound
|
||||
return ErrLibraryElementNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// getLibraryElement gets a Library Element where param == value
|
||||
func (l *LibraryElementService) getLibraryElements(c *models.ReqContext, params []Pair) ([]LibraryElementDTO, error) {
|
||||
// getLibraryElements gets a Library Element where param == value
|
||||
func getLibraryElements(c *models.ReqContext, store *sqlstore.SQLStore, params []Pair) ([]LibraryElementDTO, error) {
|
||||
libraryElements := make([]LibraryElementWithMeta, 0)
|
||||
err := l.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error {
|
||||
err := store.WithDbSession(c.Req.Context(), func(session *sqlstore.DBSession) error {
|
||||
builder := sqlstore.SQLBuilder{}
|
||||
builder.Write(selectLibraryElementDTOWithMeta)
|
||||
builder.Write(", 'General' as folder_name ")
|
||||
builder.Write(", '' as folder_uid ")
|
||||
builder.Write(getFromLibraryElementDTOWithMeta(l.SQLStore.Dialect))
|
||||
builder.Write(getFromLibraryElementDTOWithMeta(store.Dialect))
|
||||
writeParamSelectorSQL(&builder, append(params, Pair{"folder_id", 0})...)
|
||||
builder.Write(" UNION ")
|
||||
builder.Write(selectLibraryElementDTOWithMeta)
|
||||
builder.Write(", dashboard.title as folder_name ")
|
||||
builder.Write(", dashboard.uid as folder_uid ")
|
||||
builder.Write(getFromLibraryElementDTOWithMeta(l.SQLStore.Dialect))
|
||||
builder.Write(getFromLibraryElementDTOWithMeta(store.Dialect))
|
||||
builder.Write(" INNER JOIN dashboard AS dashboard on le.folder_id = dashboard.id AND le.folder_id <> 0")
|
||||
writeParamSelectorSQL(&builder, params...)
|
||||
if c.SignedInUser.OrgRole != models.ROLE_ADMIN {
|
||||
@@ -228,7 +228,7 @@ func (l *LibraryElementService) getLibraryElements(c *models.ReqContext, params
|
||||
return err
|
||||
}
|
||||
if len(libraryElements) == 0 {
|
||||
return errLibraryElementNotFound
|
||||
return ErrLibraryElementNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -274,8 +274,8 @@ func (l *LibraryElementService) getLibraryElements(c *models.ReqContext, params
|
||||
}
|
||||
|
||||
// getLibraryElementByUid gets a Library Element by uid.
|
||||
func (l *LibraryElementService) getLibraryElementByUid(c *models.ReqContext) (LibraryElementDTO, error) {
|
||||
libraryElements, err := l.getLibraryElements(c, []Pair{{key: "org_id", value: c.SignedInUser.OrgId}, {key: "uid", value: macaron.Params(c.Req)[":uid"]}})
|
||||
func (l *LibraryElementService) getLibraryElementByUid(c *models.ReqContext, UID string) (LibraryElementDTO, error) {
|
||||
libraryElements, err := getLibraryElements(c, l.SQLStore, []Pair{{key: "org_id", value: c.SignedInUser.OrgId}, {key: "uid", value: UID}})
|
||||
if err != nil {
|
||||
return LibraryElementDTO{}, err
|
||||
}
|
||||
@@ -288,7 +288,7 @@ func (l *LibraryElementService) getLibraryElementByUid(c *models.ReqContext) (Li
|
||||
|
||||
// getLibraryElementByName gets a Library Element by name.
|
||||
func (l *LibraryElementService) getLibraryElementsByName(c *models.ReqContext) ([]LibraryElementDTO, error) {
|
||||
return l.getLibraryElements(c, []Pair{{"org_id", c.SignedInUser.OrgId}, {"name", macaron.Params(c.Req)[":name"]}})
|
||||
return getLibraryElements(c, l.SQLStore, []Pair{{"org_id", c.SignedInUser.OrgId}, {"name", macaron.Params(c.Req)[":name"]}})
|
||||
}
|
||||
|
||||
// getAllLibraryElements gets all Library Elements.
|
||||
@@ -458,7 +458,7 @@ func (l *LibraryElementService) patchLibraryElement(c *models.ReqContext, cmd pa
|
||||
}
|
||||
|
||||
_, err := getLibraryElement(l.SQLStore.Dialect, session, updateUID, c.SignedInUser.OrgId)
|
||||
if !errors.Is(err, errLibraryElementNotFound) {
|
||||
if !errors.Is(err, ErrLibraryElementNotFound) {
|
||||
return errLibraryElementAlreadyExists
|
||||
}
|
||||
}
|
||||
@@ -498,7 +498,7 @@ func (l *LibraryElementService) patchLibraryElement(c *models.ReqContext, cmd pa
|
||||
}
|
||||
return err
|
||||
} else if rowsAffected != 1 {
|
||||
return errLibraryElementNotFound
|
||||
return ErrLibraryElementNotFound
|
||||
}
|
||||
|
||||
dto = LibraryElementDTO{
|
||||
|
||||
@@ -22,6 +22,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, routeRegister
|
||||
// Service is a service for operating on library elements.
|
||||
type Service interface {
|
||||
CreateElement(c *models.ReqContext, cmd CreateLibraryElementCommand) (LibraryElementDTO, error)
|
||||
GetElement(c *models.ReqContext, UID string) (LibraryElementDTO, error)
|
||||
GetElementsForDashboard(c *models.ReqContext, dashboardID int64) (map[string]LibraryElementDTO, error)
|
||||
ConnectElementsToDashboard(c *models.ReqContext, elementUIDs []string, dashboardID int64) error
|
||||
DisconnectElementsFromDashboard(c *models.ReqContext, dashboardID int64) error
|
||||
@@ -41,6 +42,11 @@ func (l *LibraryElementService) CreateElement(c *models.ReqContext, cmd CreateLi
|
||||
return l.createLibraryElement(c, cmd)
|
||||
}
|
||||
|
||||
// GetElement gets an element from a UID.
|
||||
func (l *LibraryElementService) GetElement(c *models.ReqContext, UID string) (LibraryElementDTO, error) {
|
||||
return l.getLibraryElementByUid(c, UID)
|
||||
}
|
||||
|
||||
// GetElementsForDashboard gets all connected elements for a specific dashboard.
|
||||
func (l *LibraryElementService) GetElementsForDashboard(c *models.ReqContext, dashboardID int64) (map[string]LibraryElementDTO, error) {
|
||||
return l.getElementsForDashboardID(c, dashboardID)
|
||||
|
||||
@@ -137,8 +137,8 @@ type LibraryElementConnectionDTO struct {
|
||||
var (
|
||||
// errLibraryElementAlreadyExists is an error for when the user tries to add a library element that already exists.
|
||||
errLibraryElementAlreadyExists = errors.New("library element with that name or UID already exists")
|
||||
// errLibraryElementNotFound is an error for when a library element can't be found.
|
||||
errLibraryElementNotFound = errors.New("library element could not be found")
|
||||
// ErrLibraryElementNotFound is an error for when a library element can't be found.
|
||||
ErrLibraryElementNotFound = errors.New("library element could not be found")
|
||||
// errLibraryElementDashboardNotFound is an error for when a library element connection can't be found.
|
||||
errLibraryElementDashboardNotFound = errors.New("library element connection could not be found")
|
||||
// errLibraryElementHasConnections is an error for when an user deletes a library element that is connected.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package librarypanels
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
@@ -28,6 +30,7 @@ type Service interface {
|
||||
LoadLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard) error
|
||||
CleanLibraryPanelsForDashboard(dash *models.Dashboard) error
|
||||
ConnectLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard) error
|
||||
ImportLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard, folderID int64) error
|
||||
}
|
||||
|
||||
// LibraryPanelService is the service for the Panel Library feature.
|
||||
@@ -70,20 +73,20 @@ func loadLibraryPanelsRecursively(elements map[string]libraryelements.LibraryEle
|
||||
}
|
||||
|
||||
// we have a library panel
|
||||
uid := libraryPanel.Get("uid").MustString()
|
||||
if len(uid) == 0 {
|
||||
UID := libraryPanel.Get("uid").MustString()
|
||||
if len(UID) == 0 {
|
||||
return errLibraryPanelHeaderUIDMissing
|
||||
}
|
||||
|
||||
elementInDB, ok := elements[uid]
|
||||
elementInDB, ok := elements[UID]
|
||||
if !ok {
|
||||
name := libraryPanel.Get("name").MustString()
|
||||
elem := parent.Get("panels").GetIndex(i)
|
||||
elem.Set("gridPos", panelAsJSON.Get("gridPos").MustMap())
|
||||
elem.Set("id", panelAsJSON.Get("id").MustInt64())
|
||||
elem.Set("type", fmt.Sprintf("Name: \"%s\", UID: \"%s\"", name, uid))
|
||||
elem.Set("type", fmt.Sprintf("Name: \"%s\", UID: \"%s\"", name, UID))
|
||||
elem.Set("libraryPanel", map[string]interface{}{
|
||||
"uid": uid,
|
||||
"uid": UID,
|
||||
"name": name,
|
||||
})
|
||||
continue
|
||||
@@ -166,8 +169,8 @@ func cleanLibraryPanelsRecursively(parent *simplejson.Json) error {
|
||||
}
|
||||
|
||||
// we have a library panel
|
||||
uid := libraryPanel.Get("uid").MustString()
|
||||
if len(uid) == 0 {
|
||||
UID := libraryPanel.Get("uid").MustString()
|
||||
if len(UID) == 0 {
|
||||
return errLibraryPanelHeaderUIDMissing
|
||||
}
|
||||
name := libraryPanel.Get("name").MustString()
|
||||
@@ -177,12 +180,12 @@ func cleanLibraryPanelsRecursively(parent *simplejson.Json) error {
|
||||
|
||||
// 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))
|
||||
ID := panelAsJSON.Get("id").MustInt64(int64(i))
|
||||
parent.Get("panels").SetIndex(i, map[string]interface{}{
|
||||
"id": id,
|
||||
"id": ID,
|
||||
"gridPos": gridPos,
|
||||
"libraryPanel": map[string]interface{}{
|
||||
"uid": uid,
|
||||
"uid": UID,
|
||||
"name": name,
|
||||
},
|
||||
})
|
||||
@@ -232,15 +235,85 @@ func connectLibraryPanelsRecursively(c *models.ReqContext, panels []interface{},
|
||||
}
|
||||
|
||||
// we have a library panel
|
||||
uid := libraryPanel.Get("uid").MustString()
|
||||
if len(uid) == 0 {
|
||||
UID := libraryPanel.Get("uid").MustString()
|
||||
if len(UID) == 0 {
|
||||
return errLibraryPanelHeaderUIDMissing
|
||||
}
|
||||
_, exists := libraryPanels[uid]
|
||||
_, exists := libraryPanels[UID]
|
||||
if !exists {
|
||||
libraryPanels[uid] = uid
|
||||
libraryPanels[UID] = UID
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportLibraryPanelsForDashboard loops through all panels in dashboard JSON and creates any missing library panels in the database.
|
||||
func (lps *LibraryPanelService) ImportLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard, folderID int64) error {
|
||||
return importLibraryPanelsRecursively(c, lps.LibraryElementService, dash.Data, folderID)
|
||||
}
|
||||
|
||||
func importLibraryPanelsRecursively(c *models.ReqContext, service libraryelements.Service, parent *simplejson.Json, folderID int64) error {
|
||||
panels := parent.Get("panels").MustArray()
|
||||
for _, panel := range panels {
|
||||
panelAsJSON := simplejson.NewFromAny(panel)
|
||||
libraryPanel := panelAsJSON.Get("libraryPanel")
|
||||
panelType := panelAsJSON.Get("type").MustString()
|
||||
if !isLibraryPanelOrRow(libraryPanel, panelType) {
|
||||
continue
|
||||
}
|
||||
|
||||
// we have a row
|
||||
if panelType == "row" {
|
||||
err := importLibraryPanelsRecursively(c, service, panelAsJSON, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_, err := service.GetElement(c, UID)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, libraryelements.ErrLibraryElementNotFound) {
|
||||
panelAsJSON.Set("libraryPanel",
|
||||
map[string]interface{}{
|
||||
"uid": UID,
|
||||
"name": name,
|
||||
})
|
||||
Model, err := json.Marshal(&panelAsJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cmd = libraryelements.CreateLibraryElementCommand{
|
||||
FolderID: folderID,
|
||||
Name: name,
|
||||
Model: Model,
|
||||
Kind: int64(models.PanelElement),
|
||||
UID: UID,
|
||||
}
|
||||
_, err = service.CreateElement(c, cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/macaron.v1"
|
||||
|
||||
@@ -22,11 +21,11 @@ import (
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
const UserInDbName = "user_in_db"
|
||||
const UserInDbAvatar = "/avatar/402d08de060496d6b6874495fe20f5ad"
|
||||
const userInDbName = "user_in_db"
|
||||
const userInDbAvatar = "/avatar/402d08de060496d6b6874495fe20f5ad"
|
||||
|
||||
func TestLoadLibraryPanelsForDashboard(t *testing.T) {
|
||||
scenarioWithLibraryPanel(t, "When an admin tries to load a dashboard with a library panel, it should copy JSON properties from library panel",
|
||||
scenarioWithLibraryPanel(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) {
|
||||
dashJSON := map[string]interface{}{
|
||||
"panels": []interface{}{
|
||||
@@ -102,13 +101,13 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) {
|
||||
"updated": sc.initialResult.Result.Meta.Updated,
|
||||
"createdBy": map[string]interface{}{
|
||||
"id": sc.initialResult.Result.Meta.CreatedBy.ID,
|
||||
"name": UserInDbName,
|
||||
"avatarUrl": UserInDbAvatar,
|
||||
"name": userInDbName,
|
||||
"avatarUrl": userInDbAvatar,
|
||||
},
|
||||
"updatedBy": map[string]interface{}{
|
||||
"id": sc.initialResult.Result.Meta.UpdatedBy.ID,
|
||||
"name": UserInDbName,
|
||||
"avatarUrl": UserInDbAvatar,
|
||||
"name": userInDbName,
|
||||
"avatarUrl": userInDbAvatar,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -276,13 +275,13 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) {
|
||||
"updated": sc.initialResult.Result.Meta.Updated,
|
||||
"createdBy": map[string]interface{}{
|
||||
"id": sc.initialResult.Result.Meta.CreatedBy.ID,
|
||||
"name": UserInDbName,
|
||||
"avatarUrl": UserInDbAvatar,
|
||||
"name": userInDbName,
|
||||
"avatarUrl": userInDbAvatar,
|
||||
},
|
||||
"updatedBy": map[string]interface{}{
|
||||
"id": sc.initialResult.Result.Meta.UpdatedBy.ID,
|
||||
"name": UserInDbName,
|
||||
"avatarUrl": UserInDbAvatar,
|
||||
"name": userInDbName,
|
||||
"avatarUrl": userInDbAvatar,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -315,13 +314,13 @@ func TestLoadLibraryPanelsForDashboard(t *testing.T) {
|
||||
"updated": outsidePanel.Meta.Updated,
|
||||
"createdBy": map[string]interface{}{
|
||||
"id": outsidePanel.Meta.CreatedBy.ID,
|
||||
"name": UserInDbName,
|
||||
"avatarUrl": UserInDbAvatar,
|
||||
"name": userInDbName,
|
||||
"avatarUrl": userInDbAvatar,
|
||||
},
|
||||
"updatedBy": map[string]interface{}{
|
||||
"id": outsidePanel.Meta.UpdatedBy.ID,
|
||||
"name": UserInDbName,
|
||||
"avatarUrl": UserInDbAvatar,
|
||||
"name": userInDbName,
|
||||
"avatarUrl": userInDbAvatar,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1048,6 +1047,223 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestImportLibraryPanelsForDashboard(t *testing.T) {
|
||||
testScenario(t, "When an admin tries to import a dashboard with a library panel that does not exist, it should import the library panel",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
var missingUID = "jL6MrxCMz"
|
||||
var missingName = "Missing Library Panel"
|
||||
var missingModel = map[string]interface{}{
|
||||
"id": int64(2),
|
||||
"gridPos": map[string]interface{}{
|
||||
"h": int64(6),
|
||||
"w": int64(6),
|
||||
"x": int64(0),
|
||||
"y": int64(0),
|
||||
},
|
||||
"description": "",
|
||||
"datasource": "${DS_GDEV-TESTDATA}",
|
||||
"libraryPanel": map[string]interface{}{
|
||||
"uid": missingUID,
|
||||
"name": missingName,
|
||||
},
|
||||
"title": "Text - Library Panel",
|
||||
"type": "text",
|
||||
}
|
||||
|
||||
dashJSON := map[string]interface{}{
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": int64(1),
|
||||
"gridPos": map[string]interface{}{
|
||||
"h": 6,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
missingModel,
|
||||
},
|
||||
}
|
||||
dash := models.Dashboard{
|
||||
Title: "Testing ImportLibraryPanelsForDashboard",
|
||||
Data: simplejson.NewFromAny(dashJSON),
|
||||
}
|
||||
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
|
||||
_, err := sc.elementService.GetElement(sc.reqContext, missingUID)
|
||||
require.EqualError(t, err, libraryelements.ErrLibraryElementNotFound.Error())
|
||||
|
||||
err = sc.service.ImportLibraryPanelsForDashboard(sc.reqContext, dashInDB, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
element, err := sc.elementService.GetElement(sc.reqContext, missingUID)
|
||||
require.NoError(t, err)
|
||||
var expected = getExpected(t, element, missingUID, missingName, missingModel)
|
||||
var result = toLibraryElement(t, element)
|
||||
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
|
||||
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
scenarioWithLibraryPanel(t, "When an admin tries to import a dashboard with a library panel that already exist, it should not import the library panel and existing library panel should be unchanged",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
var existingUID = sc.initialResult.Result.UID
|
||||
var existingName = sc.initialResult.Result.Name
|
||||
|
||||
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(1),
|
||||
"description": "Updated description",
|
||||
"datasource": "Updated datasource",
|
||||
"libraryPanel": map[string]interface{}{
|
||||
"uid": sc.initialResult.Result.UID,
|
||||
"name": sc.initialResult.Result.Name,
|
||||
},
|
||||
"title": "Updated Title",
|
||||
"type": "stat",
|
||||
},
|
||||
},
|
||||
}
|
||||
dash := models.Dashboard{
|
||||
Title: "Testing ImportLibraryPanelsForDashboard",
|
||||
Data: simplejson.NewFromAny(dashJSON),
|
||||
}
|
||||
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
|
||||
_, err := sc.elementService.GetElement(sc.reqContext, existingUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = sc.service.ImportLibraryPanelsForDashboard(sc.reqContext, dashInDB, sc.folder.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
element, err := sc.elementService.GetElement(sc.reqContext, existingUID)
|
||||
require.NoError(t, err)
|
||||
var expected = getExpected(t, element, existingUID, existingName, sc.initialResult.Result.Model)
|
||||
expected.FolderID = sc.initialResult.Result.FolderID
|
||||
expected.Description = sc.initialResult.Result.Description
|
||||
expected.Meta.FolderUID = sc.folder.Uid
|
||||
expected.Meta.FolderName = sc.folder.Title
|
||||
var result = toLibraryElement(t, element)
|
||||
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
|
||||
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
testScenario(t, "When an admin tries to import a dashboard with library panels inside and outside of rows, it should import all that do not exist",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
var outsideUID = "jL6MrxCMz"
|
||||
var outsideName = "Outside Library Panel"
|
||||
var outsideModel = map[string]interface{}{
|
||||
"id": int64(5),
|
||||
"gridPos": map[string]interface{}{
|
||||
"h": 6,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 19,
|
||||
},
|
||||
"datasource": "${DS_GDEV-TESTDATA}",
|
||||
"libraryPanel": map[string]interface{}{
|
||||
"uid": outsideUID,
|
||||
"name": outsideName,
|
||||
},
|
||||
"title": "Outside row",
|
||||
"type": "text",
|
||||
}
|
||||
var insideUID = "iK7NsyDNz"
|
||||
var insideName = "Inside Library Panel"
|
||||
var insideModel = map[string]interface{}{
|
||||
"id": int64(4),
|
||||
"gridPos": map[string]interface{}{
|
||||
"h": 6,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 13,
|
||||
},
|
||||
"datasource": "${DS_GDEV-TESTDATA}",
|
||||
"libraryPanel": map[string]interface{}{
|
||||
"uid": insideUID,
|
||||
"name": insideName,
|
||||
},
|
||||
"title": "Inside row",
|
||||
"type": "text",
|
||||
}
|
||||
|
||||
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{}{
|
||||
"collapsed": true,
|
||||
"gridPos": map[string]interface{}{
|
||||
"h": 6,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 6,
|
||||
},
|
||||
"id": int64(2),
|
||||
"type": "row",
|
||||
"panels": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": int64(3),
|
||||
"gridPos": map[string]interface{}{
|
||||
"h": 6,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 7,
|
||||
},
|
||||
},
|
||||
insideModel,
|
||||
},
|
||||
},
|
||||
outsideModel,
|
||||
},
|
||||
}
|
||||
dash := models.Dashboard{
|
||||
Title: "Testing ImportLibraryPanelsForDashboard",
|
||||
Data: simplejson.NewFromAny(dashJSON),
|
||||
}
|
||||
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
|
||||
_, err := sc.elementService.GetElement(sc.reqContext, outsideUID)
|
||||
require.EqualError(t, err, libraryelements.ErrLibraryElementNotFound.Error())
|
||||
_, err = sc.elementService.GetElement(sc.reqContext, insideUID)
|
||||
require.EqualError(t, err, libraryelements.ErrLibraryElementNotFound.Error())
|
||||
|
||||
err = sc.service.ImportLibraryPanelsForDashboard(sc.reqContext, dashInDB, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
element, err := sc.elementService.GetElement(sc.reqContext, outsideUID)
|
||||
require.NoError(t, err)
|
||||
expected := getExpected(t, element, outsideUID, outsideName, outsideModel)
|
||||
result := toLibraryElement(t, element)
|
||||
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
|
||||
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
element, err = sc.elementService.GetElement(sc.reqContext, insideUID)
|
||||
require.NoError(t, err)
|
||||
expected = getExpected(t, element, insideUID, insideName, insideModel)
|
||||
result = toLibraryElement(t, element)
|
||||
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
|
||||
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type libraryPanel struct {
|
||||
ID int64
|
||||
OrgID int64
|
||||
@@ -1061,6 +1277,42 @@ type libraryPanel struct {
|
||||
Meta libraryelements.LibraryElementDTOMeta
|
||||
}
|
||||
|
||||
type libraryElementGridPos struct {
|
||||
H int64 `json:"h"`
|
||||
W int64 `json:"w"`
|
||||
X int64 `json:"x"`
|
||||
Y int64 `json:"y"`
|
||||
}
|
||||
|
||||
type libraryElementLibraryPanel struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type libraryElementModel struct {
|
||||
ID int64 `json:"id"`
|
||||
Datasource string `json:"datasource"`
|
||||
Description string `json:"description"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
GridPos libraryElementGridPos `json:"gridPos"`
|
||||
LibraryPanel libraryElementLibraryPanel `json:"libraryPanel"`
|
||||
}
|
||||
|
||||
type libraryElement struct {
|
||||
ID int64 `json:"id"`
|
||||
OrgID int64 `json:"orgId"`
|
||||
FolderID int64 `json:"folderId"`
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Kind int64 `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Model libraryElementModel `json:"model"`
|
||||
Version int64 `json:"version"`
|
||||
Meta libraryelements.LibraryElementDTOMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type libraryPanelResult struct {
|
||||
Result libraryPanel `json:"result"`
|
||||
}
|
||||
@@ -1081,6 +1333,80 @@ type folderACLItem struct {
|
||||
permission models.PermissionType
|
||||
}
|
||||
|
||||
func toLibraryElement(t *testing.T, res libraryelements.LibraryElementDTO) libraryElement {
|
||||
var model = libraryElementModel{}
|
||||
err := json.Unmarshal(res.Model, &model)
|
||||
require.NoError(t, err)
|
||||
|
||||
return libraryElement{
|
||||
ID: res.ID,
|
||||
OrgID: res.OrgID,
|
||||
FolderID: res.FolderID,
|
||||
UID: res.UID,
|
||||
Name: res.Name,
|
||||
Type: res.Type,
|
||||
Description: res.Description,
|
||||
Kind: res.Kind,
|
||||
Model: model,
|
||||
Version: res.Version,
|
||||
Meta: libraryelements.LibraryElementDTOMeta{
|
||||
FolderName: res.Meta.FolderName,
|
||||
FolderUID: res.Meta.FolderUID,
|
||||
ConnectedDashboards: res.Meta.ConnectedDashboards,
|
||||
Created: res.Meta.Created,
|
||||
Updated: res.Meta.Updated,
|
||||
CreatedBy: libraryelements.LibraryElementDTOMetaUser{
|
||||
ID: res.Meta.CreatedBy.ID,
|
||||
Name: res.Meta.CreatedBy.Name,
|
||||
AvatarURL: res.Meta.CreatedBy.AvatarURL,
|
||||
},
|
||||
UpdatedBy: libraryelements.LibraryElementDTOMetaUser{
|
||||
ID: res.Meta.UpdatedBy.ID,
|
||||
Name: res.Meta.UpdatedBy.Name,
|
||||
AvatarURL: res.Meta.UpdatedBy.AvatarURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getExpected(t *testing.T, res libraryelements.LibraryElementDTO, UID string, name string, model map[string]interface{}) libraryElement {
|
||||
marshalled, err := json.Marshal(model)
|
||||
require.NoError(t, err)
|
||||
var libModel libraryElementModel
|
||||
err = json.Unmarshal(marshalled, &libModel)
|
||||
require.NoError(t, err)
|
||||
|
||||
return libraryElement{
|
||||
ID: res.ID,
|
||||
OrgID: 1,
|
||||
FolderID: 0,
|
||||
UID: UID,
|
||||
Name: name,
|
||||
Type: "text",
|
||||
Description: "",
|
||||
Kind: 1,
|
||||
Model: libModel,
|
||||
Version: 1,
|
||||
Meta: libraryelements.LibraryElementDTOMeta{
|
||||
FolderName: "General",
|
||||
FolderUID: "",
|
||||
ConnectedDashboards: 0,
|
||||
Created: res.Meta.Created,
|
||||
Updated: res.Meta.Updated,
|
||||
CreatedBy: libraryelements.LibraryElementDTOMetaUser{
|
||||
ID: 1,
|
||||
Name: userInDbName,
|
||||
AvatarURL: userInDbAvatar,
|
||||
},
|
||||
UpdatedBy: libraryelements.LibraryElementDTOMetaUser{
|
||||
ID: 1,
|
||||
Name: userInDbName,
|
||||
AvatarURL: userInDbAvatar,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user models.SignedInUser, dash *models.Dashboard, folderID int64) *models.Dashboard {
|
||||
dash.FolderId = folderID
|
||||
dashItem := &dashboards.SaveDashboardDTO{
|
||||
@@ -1223,7 +1549,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
|
||||
cmd := models.CreateUserCommand{
|
||||
Email: "user.in.db@test.com",
|
||||
Name: "User In DB",
|
||||
Login: UserInDbName,
|
||||
Login: userInDbName,
|
||||
}
|
||||
_, err := sqlStore.CreateUser(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { find } from 'lodash';
|
||||
import config from 'app/core/config';
|
||||
import { DashboardExporter } from './DashboardExporter';
|
||||
import { DashboardExporter, LibraryElementExport } from './DashboardExporter';
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import { PanelPluginMeta } from '@grafana/data';
|
||||
import { variableAdapters } from '../../../variables/adapters';
|
||||
import { createConstantVariableAdapter } from '../../../variables/constant/adapter';
|
||||
import { createQueryVariableAdapter } from '../../../variables/query/adapter';
|
||||
import { createDataSourceVariableAdapter } from '../../../variables/datasource/adapter';
|
||||
import { LibraryElementKind } from '../../../library-panels/types';
|
||||
|
||||
function getStub(arg: string) {
|
||||
return Promise.resolve(stubs[arg || 'gfdb']);
|
||||
@@ -84,6 +85,15 @@ describe('given dashboard with repeated panels', () => {
|
||||
targets: [{ datasource: 'other' }],
|
||||
},
|
||||
{ id: 9, datasource: '$ds' },
|
||||
{
|
||||
id: 17,
|
||||
datasource: '$ds',
|
||||
type: 'graph',
|
||||
libraryPanel: {
|
||||
name: 'Library Panel 2',
|
||||
uid: 'ah8NqyDPs',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
repeat: 'apps',
|
||||
@@ -110,6 +120,15 @@ describe('given dashboard with repeated panels', () => {
|
||||
type: 'heatmap',
|
||||
},
|
||||
{ id: 15, repeat: null, repeatPanelId: 14 },
|
||||
{
|
||||
id: 16,
|
||||
datasource: 'gfdb',
|
||||
type: 'graph',
|
||||
libraryPanel: {
|
||||
name: 'Library Panel',
|
||||
uid: 'jL6MrxCMz',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -149,7 +168,7 @@ describe('given dashboard with repeated panels', () => {
|
||||
});
|
||||
|
||||
it('should replace datasource refs in collapsed row', () => {
|
||||
const panel = exported.panels[5].panels[0];
|
||||
const panel = exported.panels[6].panels[0];
|
||||
expect(panel.datasource).toBe('${DS_GFDB}');
|
||||
});
|
||||
|
||||
@@ -236,6 +255,36 @@ describe('given dashboard with repeated panels', () => {
|
||||
const require: any = find(exported.__requires, { name: 'OtherDB_2' });
|
||||
expect(require.id).toBe('other2');
|
||||
});
|
||||
|
||||
it('should add library panels as elements', () => {
|
||||
const element: LibraryElementExport = exported.__elements.find(
|
||||
(element: LibraryElementExport) => element.uid === 'ah8NqyDPs'
|
||||
);
|
||||
expect(element.name).toBe('Library Panel 2');
|
||||
expect(element.kind).toBe(LibraryElementKind.Panel);
|
||||
expect(element.model).toEqual({
|
||||
id: 17,
|
||||
datasource: '$ds',
|
||||
type: 'graph',
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should add library panels in collapsed rows as elements', () => {
|
||||
const element: LibraryElementExport = exported.__elements.find(
|
||||
(element: LibraryElementExport) => element.uid === 'jL6MrxCMz'
|
||||
);
|
||||
expect(element.name).toBe('Library Panel');
|
||||
expect(element.kind).toBe(LibraryElementKind.Panel);
|
||||
expect(element.model).toEqual({
|
||||
id: 16,
|
||||
datasource: '${DS_GFDB}',
|
||||
type: 'graph',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Stub responses
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PanelPluginMeta } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { VariableOption, VariableRefresh } from '../../../variables/types';
|
||||
import { isConstant, isQuery } from '../../../variables/guard';
|
||||
import { LibraryElementKind } from '../../../library-panels/types';
|
||||
import { isPanelModelLibraryPanel } from '../../../library-panels/guard';
|
||||
|
||||
interface Input {
|
||||
name: string;
|
||||
@@ -36,6 +38,13 @@ interface DataSources {
|
||||
};
|
||||
}
|
||||
|
||||
export interface LibraryElementExport {
|
||||
name: string;
|
||||
uid: string;
|
||||
model: any;
|
||||
kind: LibraryElementKind;
|
||||
}
|
||||
|
||||
export class DashboardExporter {
|
||||
makeExportable(dashboard: DashboardModel) {
|
||||
// clean up repeated rows and panels,
|
||||
@@ -55,6 +64,7 @@ export class DashboardExporter {
|
||||
const datasources: DataSources = {};
|
||||
const promises: Array<Promise<void>> = [];
|
||||
const variableLookup: { [key: string]: any } = {};
|
||||
const libraryPanels: Map<string, LibraryElementExport> = new Map<string, LibraryElementExport>();
|
||||
|
||||
for (const variable of saveModel.getVariables()) {
|
||||
variableLookup[variable.name] = variable;
|
||||
@@ -132,6 +142,16 @@ export class DashboardExporter {
|
||||
}
|
||||
};
|
||||
|
||||
const processLibraryPanels = (panel: any) => {
|
||||
if (isPanelModelLibraryPanel(panel)) {
|
||||
const { libraryPanel, ...model } = panel;
|
||||
const { name, uid } = libraryPanel;
|
||||
if (!libraryPanels.has(uid)) {
|
||||
libraryPanels.set(uid, { name, uid, kind: LibraryElementKind.Panel, model });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// check up panel data sources
|
||||
for (const panel of saveModel.panels) {
|
||||
processPanel(panel);
|
||||
@@ -174,6 +194,17 @@ export class DashboardExporter {
|
||||
inputs.push(value);
|
||||
});
|
||||
|
||||
// we need to process all panels again after all the promises are resolved
|
||||
// so all data sources, variables and targets have been templateized when we process library panels
|
||||
for (const panel of saveModel.panels) {
|
||||
processLibraryPanels(panel);
|
||||
if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) {
|
||||
for (const rowPanel of panel.panels) {
|
||||
processLibraryPanels(rowPanel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// templatize constants
|
||||
for (const variable of saveModel.getVariables()) {
|
||||
if (isConstant(variable)) {
|
||||
@@ -199,6 +230,7 @@ export class DashboardExporter {
|
||||
// make inputs and requires a top thing
|
||||
const newObj: { [key: string]: {} } = {};
|
||||
newObj['__inputs'] = inputs;
|
||||
newObj['__elements'] = [...libraryPanels.values()];
|
||||
newObj['__requires'] = sortBy(requires, ['id']);
|
||||
|
||||
defaults(newObj, saveModel);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { ReactElement, useState } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, Link, useStyles2 } from '@grafana/ui';
|
||||
@@ -37,7 +37,7 @@ export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX
|
||||
title={libraryPanel.name}
|
||||
description={libraryPanel.description}
|
||||
plugin={panelPlugin}
|
||||
onClick={() => onClick(libraryPanel)}
|
||||
onClick={() => onClick?.(libraryPanel)}
|
||||
onDelete={showSecondaryActions ? () => setShowDeletionModal(true) : undefined}
|
||||
>
|
||||
<FolderLink libraryPanel={libraryPanel} />
|
||||
@@ -57,9 +57,13 @@ interface FolderLinkProps {
|
||||
libraryPanel: LibraryElementDTO;
|
||||
}
|
||||
|
||||
function FolderLink({ libraryPanel }: FolderLinkProps): JSX.Element {
|
||||
function FolderLink({ libraryPanel }: FolderLinkProps): ReactElement | null {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!libraryPanel.meta.folderUid && !libraryPanel.meta.folderName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!libraryPanel.meta.folderUid) {
|
||||
return (
|
||||
<span className={styles.metaContainer}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../types';
|
||||
import { DashboardSearchHit } from '../../search/types';
|
||||
import { getBackendSrv } from '../../../core/services/backend_srv';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
export interface GetLibraryPanelsOptions {
|
||||
searchString?: string;
|
||||
@@ -43,9 +44,16 @@ export async function getLibraryPanels({
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getLibraryPanel(uid: string): Promise<LibraryElementDTO> {
|
||||
const { result } = await getBackendSrv().get(`/api/library-elements/${uid}`);
|
||||
return result;
|
||||
export async function getLibraryPanel(uid: string, isHandled = false): Promise<LibraryElementDTO> {
|
||||
const response = await lastValueFrom(
|
||||
getBackendSrv().fetch<{ result: LibraryElementDTO }>({
|
||||
method: 'GET',
|
||||
url: `/api/library-elements/${uid}`,
|
||||
showSuccessAlert: !isHandled,
|
||||
showErrorAlert: !isHandled,
|
||||
})
|
||||
);
|
||||
return response.data.result;
|
||||
}
|
||||
|
||||
export async function getLibraryPanelByName(name: string): Promise<LibraryElementDTO[]> {
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
FormAPI,
|
||||
FormFieldErrors,
|
||||
FormsOnSubmit,
|
||||
HorizontalGroup,
|
||||
FormFieldErrors,
|
||||
Input,
|
||||
Field,
|
||||
InputControl,
|
||||
Legend,
|
||||
} from '@grafana/ui';
|
||||
import { DataSourcePicker } from '@grafana/runtime';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
import { DashboardInput, DashboardInputs, DataSourceInput, ImportDashboardDTO } from '../state/reducers';
|
||||
import { validateTitle, validateUid } from '../utils/validation';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
import {
|
||||
DashboardInput,
|
||||
DashboardInputs,
|
||||
DataSourceInput,
|
||||
ImportDashboardDTO,
|
||||
LibraryPanelInputState,
|
||||
} from '../state/reducers';
|
||||
import { validateTitle, validateUid } from '../utils/validation';
|
||||
import { ImportDashboardLibraryPanelsList } from './ImportDashboardLibraryPanelsList';
|
||||
|
||||
interface Props extends Pick<FormAPI<ImportDashboardDTO>, 'register' | 'errors' | 'control' | 'getValues' | 'watch'> {
|
||||
uidReset: boolean;
|
||||
inputs: DashboardInputs;
|
||||
@@ -41,6 +49,7 @@ export const ImportDashboardForm: FC<Props> = ({
|
||||
}) => {
|
||||
const [isSubmitted, setSubmitted] = useState(false);
|
||||
const watchDataSources = watch('dataSources');
|
||||
const watchFolder = watch('folder');
|
||||
|
||||
/*
|
||||
This useEffect is needed for overwriting a dashboard. It
|
||||
@@ -51,6 +60,8 @@ export const ImportDashboardForm: FC<Props> = ({
|
||||
onSubmit(getValues(), {} as any);
|
||||
}
|
||||
}, [errors, getValues, isSubmitted, onSubmit]);
|
||||
const newLibraryPanels = inputs?.libraryPanels?.filter((i) => i.state === LibraryPanelInputState.New) ?? [];
|
||||
const existingLibraryPanels = inputs?.libraryPanels?.filter((i) => i.state === LibraryPanelInputState.Exits) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -136,6 +147,18 @@ export const ImportDashboardForm: FC<Props> = ({
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
<ImportDashboardLibraryPanelsList
|
||||
inputs={newLibraryPanels}
|
||||
label="New library panels"
|
||||
description="List of new library panels that will get imported."
|
||||
folderName={watchFolder.title}
|
||||
/>
|
||||
<ImportDashboardLibraryPanelsList
|
||||
inputs={existingLibraryPanels}
|
||||
label="Existing library panels"
|
||||
description="List of existing library panels. These panels are not affected by the import."
|
||||
folderName={watchFolder.title}
|
||||
/>
|
||||
<HorizontalGroup>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { Field, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { LibraryPanelInput, LibraryPanelInputState } from '../state/reducers';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { css } from '@emotion/css';
|
||||
import { LibraryPanelCard } from '../../library-panels/components/LibraryPanelCard/LibraryPanelCard';
|
||||
|
||||
interface ImportDashboardLibraryPanelsListProps {
|
||||
inputs: LibraryPanelInput[];
|
||||
label: string;
|
||||
description: string;
|
||||
folderName?: string;
|
||||
}
|
||||
|
||||
export function ImportDashboardLibraryPanelsList({
|
||||
inputs,
|
||||
label,
|
||||
description,
|
||||
folderName,
|
||||
}: ImportDashboardLibraryPanelsListProps): ReactElement | null {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!Boolean(inputs?.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.spacer}>
|
||||
<Field label={label} description={description}>
|
||||
<>
|
||||
{inputs.map((input, index) => {
|
||||
const libraryPanelIndex = `elements[${index}]`;
|
||||
const libraryPanel =
|
||||
input.state === LibraryPanelInputState.New
|
||||
? { ...input.model, meta: { ...input.model.meta, folderName: folderName ?? 'General' } }
|
||||
: { ...input.model };
|
||||
return (
|
||||
<div className={styles.item} key={libraryPanelIndex}>
|
||||
<LibraryPanelCard libraryPanel={libraryPanel} onClick={() => undefined} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
spacer: css`
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`,
|
||||
item: css`
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { dateTimeFormat } from '@grafana/data';
|
||||
import { Legend, Form } from '@grafana/ui';
|
||||
import { Form, Legend } from '@grafana/ui';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { ImportDashboardForm } from './ImportDashboardForm';
|
||||
import { clearLoadedDashboard, importDashboard } from '../state/actions';
|
||||
@@ -87,7 +87,7 @@ class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> {
|
||||
)}
|
||||
<Form
|
||||
onSubmit={this.onSubmit}
|
||||
defaultValues={{ ...dashboard, constants: [], dataSources: [], folder: folder }}
|
||||
defaultValues={{ ...dashboard, constants: [], dataSources: [], elements: [], folder: folder }}
|
||||
validateOnMount
|
||||
validateFieldsOnMount={['title', 'uid']}
|
||||
validateOn="onChange"
|
||||
|
||||
@@ -4,15 +4,21 @@ import {
|
||||
clearDashboard,
|
||||
ImportDashboardDTO,
|
||||
InputType,
|
||||
LibraryPanelInput,
|
||||
LibraryPanelInputState,
|
||||
setGcomDashboard,
|
||||
setInputs,
|
||||
setJsonDashboard,
|
||||
setLibraryPanelInputs,
|
||||
} from './reducers';
|
||||
import { DashboardDataDTO, DashboardDTO, FolderInfo, PermissionLevelString, ThunkResult } from 'app/types';
|
||||
import { appEvents } from '../../../core/core';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { getDataSourceSrv, locationService } from '@grafana/runtime';
|
||||
import { DashboardSearchHit } from '../../search/types';
|
||||
import { getLibraryPanel } from '../../library-panels/state/api';
|
||||
import { LibraryElementDTO, LibraryElementKind } from '../../library-panels/types';
|
||||
import { LibraryElementExport } from '../../dashboard/components/DashExportModal/DashboardExporter';
|
||||
|
||||
export function fetchGcomDashboard(id: string): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
@@ -20,6 +26,7 @@ export function fetchGcomDashboard(id: string): ThunkResult<void> {
|
||||
const dashboard = await getBackendSrv().get(`/api/gnet/dashboards/${id}`);
|
||||
dispatch(setGcomDashboard(dashboard));
|
||||
dispatch(processInputs(dashboard.json));
|
||||
dispatch(processElements(dashboard.json));
|
||||
} catch (error) {
|
||||
appEvents.emit(AppEvents.alertError, [error.data.message || error]);
|
||||
}
|
||||
@@ -30,6 +37,7 @@ export function importDashboardJson(dashboard: any): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
dispatch(setJsonDashboard(dashboard));
|
||||
dispatch(processInputs(dashboard));
|
||||
dispatch(processElements(dashboard));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +69,54 @@ function processInputs(dashboardJson: any): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
function processElements(dashboardJson?: { __elements?: LibraryElementExport[] }): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
if (!dashboardJson || !dashboardJson.__elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const libraryPanelInputs: LibraryPanelInput[] = [];
|
||||
|
||||
for (const element of dashboardJson.__elements) {
|
||||
if (element.kind !== LibraryElementKind.Panel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = element.model;
|
||||
const { type, description } = model;
|
||||
const { uid, name } = element;
|
||||
const input: LibraryPanelInput = {
|
||||
model: {
|
||||
model,
|
||||
uid,
|
||||
name,
|
||||
version: 0,
|
||||
meta: {},
|
||||
id: 0,
|
||||
type,
|
||||
kind: LibraryElementKind.Panel,
|
||||
description,
|
||||
} as LibraryElementDTO,
|
||||
state: LibraryPanelInputState.New,
|
||||
};
|
||||
|
||||
try {
|
||||
const panelInDb = await getLibraryPanel(uid, true);
|
||||
input.state = LibraryPanelInputState.Exits;
|
||||
input.model = panelInDb;
|
||||
} catch (e: any) {
|
||||
if (e.status !== 404) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
libraryPanelInputs.push(input);
|
||||
}
|
||||
|
||||
dispatch(setLibraryPanelInputs(libraryPanelInputs));
|
||||
};
|
||||
}
|
||||
|
||||
export function clearLoadedDashboard(): ThunkResult<void> {
|
||||
return (dispatch) => {
|
||||
dispatch(clearDashboard());
|
||||
|
||||
134
public/app/features/manage-dashboards/state/reducers.test.ts
Normal file
134
public/app/features/manage-dashboards/state/reducers.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import {
|
||||
clearDashboard,
|
||||
DashboardSource,
|
||||
DataSourceInput,
|
||||
importDashboardReducer,
|
||||
ImportDashboardState,
|
||||
initialImportDashboardState,
|
||||
InputType,
|
||||
LibraryPanelInput,
|
||||
LibraryPanelInputState,
|
||||
setGcomDashboard,
|
||||
setInputs,
|
||||
setJsonDashboard,
|
||||
setLibraryPanelInputs,
|
||||
} from './reducers';
|
||||
import { LibraryElementDTO } from '../../library-panels/types';
|
||||
|
||||
describe('importDashboardReducer', () => {
|
||||
describe('when setGcomDashboard action is dispatched', () => {
|
||||
it('then resulting state should be correct', () => {
|
||||
reducerTester<ImportDashboardState>()
|
||||
.givenReducer(importDashboardReducer, { ...initialImportDashboardState })
|
||||
.whenActionIsDispatched(
|
||||
setGcomDashboard({ json: { id: 1, title: 'Imported' }, updatedAt: '2001-01-01', orgName: 'Some Org' })
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...initialImportDashboardState,
|
||||
dashboard: {
|
||||
title: 'Imported',
|
||||
id: null,
|
||||
},
|
||||
meta: { updatedAt: '2001-01-01', orgName: 'Some Org' },
|
||||
source: DashboardSource.Gcom,
|
||||
isLoaded: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setJsonDashboard action is dispatched', () => {
|
||||
it('then resulting state should be correct', () => {
|
||||
reducerTester<ImportDashboardState>()
|
||||
.givenReducer(importDashboardReducer, { ...initialImportDashboardState, source: DashboardSource.Gcom })
|
||||
.whenActionIsDispatched(setJsonDashboard({ id: 1, title: 'Imported' }))
|
||||
.thenStateShouldEqual({
|
||||
...initialImportDashboardState,
|
||||
dashboard: {
|
||||
title: 'Imported',
|
||||
id: null,
|
||||
},
|
||||
source: DashboardSource.Json,
|
||||
isLoaded: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when clearDashboard action is dispatched', () => {
|
||||
it('then resulting state should be correct', () => {
|
||||
reducerTester<ImportDashboardState>()
|
||||
.givenReducer(importDashboardReducer, {
|
||||
...initialImportDashboardState,
|
||||
dashboard: {
|
||||
title: 'Imported',
|
||||
id: null,
|
||||
},
|
||||
isLoaded: true,
|
||||
})
|
||||
.whenActionIsDispatched(clearDashboard())
|
||||
.thenStateShouldEqual({
|
||||
...initialImportDashboardState,
|
||||
dashboard: {},
|
||||
isLoaded: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setInputs action is dispatched', () => {
|
||||
it('then resulting state should be correct', () => {
|
||||
reducerTester<ImportDashboardState>()
|
||||
.givenReducer(importDashboardReducer, { ...initialImportDashboardState })
|
||||
.whenActionIsDispatched(
|
||||
setInputs([
|
||||
{ type: InputType.DataSource },
|
||||
{ type: InputType.Constant },
|
||||
{ type: InputType.LibraryPanel },
|
||||
{ type: 'temp' },
|
||||
])
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...initialImportDashboardState,
|
||||
inputs: {
|
||||
dataSources: [{ type: InputType.DataSource }] as DataSourceInput[],
|
||||
constants: [{ type: InputType.Constant }] as DataSourceInput[],
|
||||
libraryPanels: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setLibraryPanelInputs action is dispatched', () => {
|
||||
it('then resulting state should be correct', () => {
|
||||
reducerTester<ImportDashboardState>()
|
||||
.givenReducer(importDashboardReducer, {
|
||||
...initialImportDashboardState,
|
||||
inputs: {
|
||||
dataSources: [{ type: InputType.DataSource }] as DataSourceInput[],
|
||||
constants: [{ type: InputType.Constant }] as DataSourceInput[],
|
||||
libraryPanels: [{ model: { uid: 'asasAHSJ' } }] as LibraryPanelInput[],
|
||||
},
|
||||
})
|
||||
.whenActionIsDispatched(
|
||||
setLibraryPanelInputs([
|
||||
{
|
||||
model: { uid: 'sadjahsdk', name: 'A name', type: 'text' } as LibraryElementDTO,
|
||||
state: LibraryPanelInputState.Exits,
|
||||
},
|
||||
])
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...initialImportDashboardState,
|
||||
inputs: {
|
||||
dataSources: [{ type: InputType.DataSource }] as DataSourceInput[],
|
||||
constants: [{ type: InputType.Constant }] as DataSourceInput[],
|
||||
libraryPanels: [
|
||||
{
|
||||
model: { uid: 'sadjahsdk', name: 'A name', type: 'text' } as LibraryElementDTO,
|
||||
state: LibraryPanelInputState.Exits,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice, Draft, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { LibraryElementDTO } from '../../library-panels/types';
|
||||
|
||||
export enum DashboardSource {
|
||||
Gcom = 0,
|
||||
@@ -12,12 +13,20 @@ export interface ImportDashboardDTO {
|
||||
gnetId: string;
|
||||
constants: string[];
|
||||
dataSources: DataSourceInstanceSettings[];
|
||||
elements: LibraryElementDTO[];
|
||||
folder: { id: number; title?: string };
|
||||
}
|
||||
|
||||
export enum InputType {
|
||||
DataSource = 'datasource',
|
||||
Constant = 'constant',
|
||||
LibraryPanel = 'libraryPanel',
|
||||
}
|
||||
|
||||
export enum LibraryPanelInputState {
|
||||
New = 'new',
|
||||
Exits = 'exists',
|
||||
Different = 'different',
|
||||
}
|
||||
|
||||
export interface DashboardInput {
|
||||
@@ -32,9 +41,15 @@ export interface DataSourceInput extends DashboardInput {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface LibraryPanelInput {
|
||||
model: LibraryElementDTO;
|
||||
state: LibraryPanelInputState;
|
||||
}
|
||||
|
||||
export interface DashboardInputs {
|
||||
dataSources: DataSourceInput[];
|
||||
constants: DashboardInput[];
|
||||
libraryPanels: LibraryPanelInput[];
|
||||
}
|
||||
|
||||
export interface ImportDashboardState {
|
||||
@@ -45,7 +60,7 @@ export interface ImportDashboardState {
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
const initialImportDashboardState: ImportDashboardState = {
|
||||
export const initialImportDashboardState: ImportDashboardState = {
|
||||
meta: { updatedAt: '', orgName: '' },
|
||||
dashboard: {},
|
||||
source: DashboardSource.Json,
|
||||
@@ -57,47 +72,48 @@ const importDashboardSlice = createSlice({
|
||||
name: 'manageDashboards',
|
||||
initialState: initialImportDashboardState,
|
||||
reducers: {
|
||||
setGcomDashboard: (state, action: PayloadAction<any>): ImportDashboardState => {
|
||||
return {
|
||||
...state,
|
||||
dashboard: {
|
||||
...action.payload.json,
|
||||
id: null,
|
||||
},
|
||||
meta: { updatedAt: action.payload.updatedAt, orgName: action.payload.orgName },
|
||||
source: DashboardSource.Gcom,
|
||||
isLoaded: true,
|
||||
setGcomDashboard: (state: Draft<ImportDashboardState>, action: PayloadAction<any>) => {
|
||||
state.dashboard = {
|
||||
...action.payload.json,
|
||||
id: null,
|
||||
};
|
||||
state.meta = { updatedAt: action.payload.updatedAt, orgName: action.payload.orgName };
|
||||
state.source = DashboardSource.Gcom;
|
||||
state.isLoaded = true;
|
||||
},
|
||||
setJsonDashboard: (state, action: PayloadAction<any>): ImportDashboardState => {
|
||||
return {
|
||||
...state,
|
||||
dashboard: {
|
||||
...action.payload,
|
||||
id: null,
|
||||
},
|
||||
source: DashboardSource.Json,
|
||||
isLoaded: true,
|
||||
setJsonDashboard: (state: Draft<ImportDashboardState>, action: PayloadAction<any>) => {
|
||||
state.dashboard = {
|
||||
...action.payload,
|
||||
id: null,
|
||||
};
|
||||
state.meta = initialImportDashboardState.meta;
|
||||
state.source = DashboardSource.Json;
|
||||
state.isLoaded = true;
|
||||
},
|
||||
clearDashboard: (state): ImportDashboardState => {
|
||||
return {
|
||||
...state,
|
||||
dashboard: {},
|
||||
isLoaded: false,
|
||||
};
|
||||
clearDashboard: (state: Draft<ImportDashboardState>) => {
|
||||
state.dashboard = {};
|
||||
state.isLoaded = false;
|
||||
},
|
||||
setInputs: (state, action: PayloadAction<any[]>): ImportDashboardState => ({
|
||||
...state,
|
||||
inputs: {
|
||||
setInputs: (state: Draft<ImportDashboardState>, action: PayloadAction<any[]>) => {
|
||||
state.inputs = {
|
||||
dataSources: action.payload.filter((p) => p.type === InputType.DataSource),
|
||||
constants: action.payload.filter((p) => p.type === InputType.Constant),
|
||||
},
|
||||
}),
|
||||
libraryPanels: [],
|
||||
};
|
||||
},
|
||||
setLibraryPanelInputs: (state: Draft<ImportDashboardState>, action: PayloadAction<LibraryPanelInput[]>) => {
|
||||
state.inputs.libraryPanels = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearDashboard, setInputs, setGcomDashboard, setJsonDashboard } = importDashboardSlice.actions;
|
||||
export const {
|
||||
clearDashboard,
|
||||
setInputs,
|
||||
setGcomDashboard,
|
||||
setJsonDashboard,
|
||||
setLibraryPanelInputs,
|
||||
} = importDashboardSlice.actions;
|
||||
|
||||
export const importDashboardReducer = importDashboardSlice.reducer;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user