Library panel connections: Always use unistore for connection info (#121903)

This commit is contained in:
Stephanie Hingtgen 2026-04-07 09:33:53 -06:00 committed by GitHub
parent dd96b0c0ba
commit 2fbbb64ce7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 122 additions and 797 deletions

View file

@ -361,17 +361,7 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo
return response.Error(http.StatusBadRequest, "Use folders endpoint for deleting folders.", nil)
}
// disconnect all library elements for this dashboard
err := hs.LibraryElementService.DisconnectElementsFromDashboard(c.Req.Context(), dash.ID)
if err != nil {
hs.log.Error(
"Failed to disconnect library elements",
"dashboard", dash.ID,
"identity", c.GetID(),
"error", err)
}
err = hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.ID, dash.UID, c.GetOrgID())
err := hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.ID, dash.UID, c.GetOrgID())
if err != nil {
return dashboardErrResponse(err, "Failed to delete dashboard")
}

View file

@ -359,20 +359,11 @@ func (s *dashboardServiceMock) ImportDashboard(ctx context.Context, dto *dashboa
type libraryPanelServiceMock struct {
librarypanels.Service
connectLibraryPanelsForDashboardFunc func(c context.Context, signedInUser identity.Requester, dash *dashboards.Dashboard) error
importLibraryPanelsForDashboardFunc func(c context.Context, signedInUser identity.Requester, libraryPanels *simplejson.Json, panels []any, folderID int64, folderUID string) error
importLibraryPanelsForDashboardFunc func(c context.Context, signedInUser identity.Requester, libraryPanels *simplejson.Json, panels []any, folderID int64, folderUID string) error
}
var _ librarypanels.Service = (*libraryPanelServiceMock)(nil)
func (s *libraryPanelServiceMock) ConnectLibraryPanelsForDashboard(ctx context.Context, signedInUser identity.Requester, dash *dashboards.Dashboard) error {
if s.connectLibraryPanelsForDashboardFunc != nil {
return s.connectLibraryPanelsForDashboardFunc(ctx, signedInUser, dash)
}
return nil
}
func (s *libraryPanelServiceMock) ImportLibraryPanelsForDashboard(ctx context.Context, signedInUser identity.Requester, libraryPanels *simplejson.Json, panels []any, folderID int64, folderUID string) error {
if s.importLibraryPanelsForDashboardFunc != nil {
return s.importLibraryPanelsForDashboardFunc(ctx, signedInUser, libraryPanels, panels, folderID, folderUID)

View file

@ -307,35 +307,26 @@ func (l *LibraryElementService) getConnectionsHandler(c *contextmodel.ReqContext
return l.toLibraryElementError(err, "Failed to get dashboards")
}
ids, err := l.getConnectionIDs(c.Req.Context(), c.SignedInUser, libraryPanelUID)
if err != nil {
return l.toLibraryElementError(err, "Failed to get connection ids")
}
connections := make([]model.LibraryElementConnectionDTO, 0)
for _, dashboard := range dashboards {
// skip checks if the user is an admin, or if the dashboard is in the general folder
if !c.HasRole(org.RoleAdmin) && dashboard.FolderUID != "" && dashboard.FolderUID != "general" {
if err := l.requireViewPermissionsOnFolderUID(c.Req.Context(), c.SignedInUser, dashboard.FolderUID); err != nil {
continue
}
}
// best effort to get a connection id, once in unified storage, connections are not an individual resource and therefore do not have an id
connectionID, ok := ids[getConnectionKey(element.ID, dashboard.ID)] // nolint:staticcheck
if !ok {
// if we cannot get an ID from the db, instead do a best effort to return something that will be consistent and somewhat unique for the connection.
// note: the connection ID cannot be used to get, update, or delete a connection, so this is solely to keep the api returning the same fields for now,
// while we deprecate the endpoint.
hash := fnv.New64a()
_, err := fmt.Fprintf(hash, "%d:%s:%d:%d", element.ID, dashboard.UID, c.GetOrgID(), element.Meta.Created.Unix())
if err != nil {
return l.toLibraryElementError(err, "Failed to generate connection id")
}
// ensure it is positive and smaller than 9007199254740991, otherwise we will lose prescision
// in javascript, which has the safest number as 9007199254740991, compared to 9223372036854775807 in go
connectionID = int64(hash.Sum64() & ((1 << 52) - 1))
// connections are not an individual resource and therefore do not have an id
// instead, return something that will be consistent and somewhat unique for the connection.
// note: the connection ID cannot be used to get, update, or delete a connection, so this is solely to keep the api returning the same fields for now,
// while we deprecate the endpoint.
hash := fnv.New64a()
_, err := fmt.Fprintf(hash, "%d:%s:%d:%d", element.ID, dashboard.UID, c.GetOrgID(), element.Meta.Created.Unix())
if err != nil {
return l.toLibraryElementError(err, "Failed to generate connection id")
}
// ensure it is positive and smaller than 9007199254740991, otherwise we will lose prescision
// in javascript, which has the safest number as 9007199254740991, compared to 9223372036854775807 in go
connectionID := int64(hash.Sum64() & ((1 << 52) - 1))
connections = append(connections, model.LibraryElementConnectionDTO{
ID: connectionID,

View file

@ -32,8 +32,7 @@ SELECT DISTINCT
, u1.login AS created_by_name
, u1.email AS created_by_email
, u2.login AS updated_by_name
, u2.email AS updated_by_email
, (SELECT COUNT(connection_id) FROM ` + model.LibraryElementConnectionTableName + ` WHERE element_id = le.id AND kind=1) AS connected_dashboards`
, u2.email AS updated_by_email`
)
func getFromLibraryElementDTOWithMeta(dialect migrator.Dialect) string {
@ -371,6 +370,10 @@ func (l *LibraryElementService) getLibraryElements(c context.Context, store db.D
}
}
if err := l.enrichConnectedDashboards(c, signedInUser.GetOrgID(), leDtos); err != nil {
return nil, err
}
return leDtos, nil
}
@ -425,6 +428,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
if err != nil {
return model.LibraryElementSearchResult{}, err
}
retDTOs := make([]model.LibraryElementDTO, 0)
err = l.SQLStore.WithDbSession(c, func(session *db.Session) error {
builder := db.NewSqlBuilder(l.Cfg, l.features, l.SQLStore.GetDialect(), recursiveQueriesAreSupported)
if folderFilter.includeGeneralFolder {
@ -462,7 +466,6 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
}
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc()
retDTOs := make([]model.LibraryElementDTO, 0)
// Get the folder tree filtered by user permissions (cached per user per request)
folderTree, err := l.treeCache.get(c, signedInUser)
if err != nil {
@ -562,8 +565,15 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
return nil
})
if err != nil {
return result, err
}
return result, err
if err := l.enrichConnectedDashboards(c, signedInUser.GetOrgID(), result.Elements); err != nil {
return result, err
}
return result, nil
}
func (l *LibraryElementService) handleFolderIDPatches(ctx context.Context, elementToPatch *model.LibraryElement,
@ -702,192 +712,53 @@ func (l *LibraryElementService) PatchLibraryElement(c context.Context, signedInU
return nil
})
return dto, err
}
// getConnectionIDs returns a map[string]int64 with the key as elementID:connectionUID and the value as connectionID
func (l *LibraryElementService) getConnectionIDs(c context.Context, signedInUser identity.Requester, uid string) (map[string]int64, error) {
connections := map[string]int64{}
recursiveQueriesAreSupported, err := l.SQLStore.RecursiveQueriesAreSupported()
if err != nil {
return nil, err
return model.LibraryElementDTO{}, err
}
err = l.SQLStore.WithDbSession(c, func(session *db.Session) error {
var libraryElementConnections []model.LibraryElementConnectionWithMeta
builder := db.NewSqlBuilder(l.Cfg, l.features, l.SQLStore.GetDialect(), recursiveQueriesAreSupported)
builder.Write("SELECT lec.id, lec.element_id, lec.connection_id")
builder.Write(" FROM " + model.LibraryElementConnectionTableName + " AS lec ")
builder.Write(" INNER JOIN " + model.LibraryElementTableName + " AS le ON le.id = element_id")
builder.Write(" WHERE le.org_id=? AND le.uid=?", signedInUser.GetOrgID(), uid)
if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryElementConnections); err != nil {
return err
}
// if the user is not an admin, we need to filter out elements that are not in folders the user can see
for _, connection := range libraryElementConnections {
connections[getConnectionKey(connection.ElementID, connection.ConnectionID)] = connection.ID
}
dtos := []model.LibraryElementDTO{dto}
enrichErr := l.enrichConnectedDashboards(c, signedInUser.GetOrgID(), dtos)
// do not return the error, just log it, because at this point, the patch has succeeded
if enrichErr != nil {
l.log.Warn("Failed to enrich connected dashboards for library element", "uid", dto.UID, "error", enrichErr)
} else {
dto = dtos[0]
}
return nil
})
return connections, err
}
func getConnectionKey(elementID int64, connectionID int64) string {
return fmt.Sprintf("%d:%d", elementID, connectionID)
}
// getElementsForDashboardID gets all elements for a specific dashboard
func (l *LibraryElementService) getElementsForDashboardID(c context.Context, dashboardID int64) (map[string]model.LibraryElementDTO, error) {
libraryElementMap := make(map[string]model.LibraryElementDTO)
err := l.SQLStore.WithDbSession(c, func(session *db.Session) error {
var libraryElements []model.LibraryElementWithMeta
sql := selectLibraryElementDTOWithMeta +
getFromLibraryElementDTOWithMeta(l.SQLStore.GetDialect()) +
" INNER JOIN " + model.LibraryElementConnectionTableName + " AS lce ON lce.element_id = le.id AND lce.kind=1 AND lce.connection_id=?"
sess := session.SQL(sql, dashboardID)
err := sess.Find(&libraryElements)
if err != nil {
return err
}
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc()
for _, element := range libraryElements {
if element.FolderName == "" {
element.FolderName = dashboards.RootFolderName
}
if element.FolderUID == "" {
element.FolderUID = ac.GeneralFolderUID
}
libraryElementMap[element.UID] = model.LibraryElementDTO{
ID: element.ID,
OrgID: element.OrgID,
FolderID: element.FolderID, // nolint:staticcheck
UID: element.UID,
Name: element.Name,
Kind: element.Kind,
Type: element.Type,
Description: element.Description,
Model: element.Model,
Version: element.Version,
Meta: model.LibraryElementDTOMeta{
FolderName: element.FolderName,
FolderUID: element.FolderUID,
ConnectedDashboards: element.ConnectedDashboards,
Created: element.Created,
Updated: element.Updated,
CreatedBy: model.LibraryElementDTOMetaUser{
Id: element.CreatedBy,
Name: element.CreatedByName,
AvatarUrl: dtos.GetGravatarUrl(l.Cfg, element.CreatedByEmail),
},
UpdatedBy: model.LibraryElementDTOMetaUser{
Id: element.UpdatedBy,
Name: element.UpdatedByName,
AvatarUrl: dtos.GetGravatarUrl(l.Cfg, element.UpdatedByEmail),
},
},
}
}
return nil
})
return libraryElementMap, err
}
// connectElementsToDashboardID adds connections for all elements Library Elements in a Dashboard.
func (l *LibraryElementService) connectElementsToDashboardID(c context.Context, signedInUser identity.Requester, elementUIDs []string, dashboardID int64) error {
err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
_, err := session.Exec("DELETE FROM "+model.LibraryElementConnectionTableName+" WHERE kind=1 AND connection_id=?", dashboardID)
if err != nil {
return err
}
for _, elementUID := range elementUIDs {
element, err := l.GetLibraryElement(c, signedInUser, session, elementUID)
if err != nil {
return err
}
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc()
// nolint:staticcheck
if err := l.requireViewPermissionsOnFolder(c, signedInUser, element.FolderID); err != nil {
return err
}
var userID int64
if id, err := identity.UserIdentifier(signedInUser.GetID()); err == nil {
userID = id
}
connection := model.LibraryElementConnection{
ElementID: element.ID,
Kind: 1,
ConnectionID: dashboardID,
Created: time.Now(),
CreatedBy: userID,
}
if _, err := session.Insert(&connection); err != nil {
if l.SQLStore.GetDialect().IsUniqueConstraintViolation(err) {
return nil
}
return err
}
}
return nil
})
return err
}
// disconnectElementsFromDashboardID deletes connections for all Library Elements in a Dashboard.
func (l *LibraryElementService) disconnectElementsFromDashboardID(c context.Context, dashboardID int64) error {
return l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
_, err := session.Exec("DELETE FROM "+model.LibraryElementConnectionTableName+" WHERE kind=1 AND connection_id=?", dashboardID)
if err != nil {
return err
}
return nil
})
return dto, nil
}
// deleteLibraryElementsInFolderUID deletes all Library Elements in a folder.
func (l *LibraryElementService) deleteLibraryElementsInFolderUID(c context.Context, signedInUser identity.Requester, folderUID string) error {
return l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
if err := l.requireEditPermissionsOnFolderUID(c, signedInUser, folderUID); err != nil {
if errors.Is(err, dashboards.ErrDashboardNotFound) {
return dashboards.ErrFolderNotFound
}
return err
if err := l.requireEditPermissionsOnFolderUID(c, signedInUser, folderUID); err != nil {
if errors.Is(err, dashboards.ErrDashboardNotFound) {
return dashboards.ErrFolderNotFound
}
var connectionIDs []struct {
ConnectionID int64 `xorm:"connection_id"`
}
sql := "SELECT lec.connection_id FROM library_element AS le"
sql += " INNER JOIN " + model.LibraryElementConnectionTableName + " AS lec on le.id = lec.element_id"
sql += " WHERE le.folder_uid=? AND le.org_id=?"
err := session.SQL(sql, folderUID, signedInUser.GetOrgID()).Find(&connectionIDs)
return err
}
var elements []struct {
UID string `xorm:"uid"`
}
err := l.SQLStore.WithDbSession(c, func(session *db.Session) error {
return session.SQL("SELECT uid FROM library_element WHERE folder_uid=? AND org_id=?", folderUID, signedInUser.GetOrgID()).Find(&elements)
})
if err != nil {
return err
}
serviceCtx, _ := identity.WithServiceIdentity(c, signedInUser.GetOrgID())
for _, elem := range elements {
connectedDashboards, err := l.dashboardsService.GetDashboardsByLibraryPanelUID(serviceCtx, elem.UID, signedInUser.GetOrgID())
if err != nil {
return err
}
if len(connectionIDs) > 0 {
if len(connectedDashboards) > 0 {
return model.ErrFolderHasConnectedLibraryElements
}
}
var elementIDs []struct {
ID int64 `xorm:"id"`
}
err = session.SQL("SELECT id from library_element WHERE folder_uid=? AND org_id=?", folderUID, signedInUser.GetOrgID()).Find(&elementIDs)
if err != nil {
return err
}
for _, elementID := range elementIDs {
_, err := session.Exec("DELETE FROM "+model.LibraryElementConnectionTableName+" WHERE element_id=?", elementID.ID)
if err != nil {
return err
}
}
return l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
if _, err := session.Exec("DELETE FROM library_element WHERE folder_uid=? AND org_id=?", folderUID, signedInUser.GetOrgID()); err != nil {
return err
}
@ -918,3 +789,17 @@ func getFoldersWithMatchingTitles(c context.Context, l *LibraryElementService, s
}
return foldersWithMatchingTitles, nil
}
// enrichConnectedDashboards populates the ConnectedDashboards count on each
// DTO by querying unified search.
func (l *LibraryElementService) enrichConnectedDashboards(ctx context.Context, orgID int64, dtos []model.LibraryElementDTO) error {
serviceCtx, _ := identity.WithServiceIdentity(ctx, orgID)
for i := range dtos {
dashboards, err := l.dashboardsService.GetDashboardsByLibraryPanelUID(serviceCtx, dtos[i].UID, orgID)
if err != nil {
return err
}
dtos[i].Meta.ConnectedDashboards = int64(len(dashboards))
}
return nil
}

View file

@ -95,18 +95,6 @@ func (l *LibraryElementService) GetElement(c context.Context, signedInUser ident
return libraryElement, nil
}
func (l *LibraryElementService) GetElementsForDashboard(c context.Context, dashboardID int64) (map[string]model.LibraryElementDTO, error) {
return map[string]model.LibraryElementDTO{}, nil
}
func (l *LibraryElementService) ConnectElementsToDashboard(c context.Context, signedInUser identity.Requester, elementUIDs []string, dashboardID int64) error {
return nil
}
func (l *LibraryElementService) DisconnectElementsFromDashboard(c context.Context, dashboardID int64) error {
return nil
}
func (l *LibraryElementService) DeleteLibraryElementsInFolder(c context.Context, signedInUser identity.Requester, folderUID string) error {
return nil
}

View file

@ -2,7 +2,6 @@ package libraryelements
import (
"context"
"strconv"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@ -51,22 +50,6 @@ func (l *LibraryElementService) requireEditPermissionsOnFolderUID(ctx context.Co
return nil
}
func (l *LibraryElementService) requireViewPermissionsOnFolder(ctx context.Context, user identity.Requester, folderID int64) error {
evaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersRead, dashboards.ScopeFoldersProvider.GetResourceScope(strconv.FormatInt(folderID, 10)))
if isGeneralFolder(folderID) {
evaluator = accesscontrol.EvalPermission(dashboards.ActionFoldersRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID))
}
canView, err := l.AccessControl.Evaluate(ctx, user, evaluator)
if err != nil {
return err
}
if !canView {
return dashboards.ErrFolderAccessDenied
}
return nil
}
func (l *LibraryElementService) requireViewPermissionsOnFolderUID(ctx context.Context, user identity.Requester, folderUID string) error {
evaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID))
canView, err := l.AccessControl.Evaluate(ctx, user, evaluator)

View file

@ -47,9 +47,6 @@ type Service interface {
PatchLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error)
DeleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error)
GetElement(c context.Context, signedInUser identity.Requester, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error)
GetElementsForDashboard(c context.Context, dashboardID int64) (map[string]model.LibraryElementDTO, error)
ConnectElementsToDashboard(c context.Context, signedInUser identity.Requester, elementUIDs []string, dashboardID int64) error
DisconnectElementsFromDashboard(c context.Context, dashboardID int64) error
DeleteLibraryElementsInFolder(c context.Context, signedInUser identity.Requester, folderUID string) error
GetAllElements(c context.Context, signedInUser identity.Requester, query model.SearchLibraryElementsQuery) (model.LibraryElementSearchResult, error)
}
@ -75,21 +72,6 @@ func (l *LibraryElementService) GetElement(c context.Context, signedInUser ident
return l.getLibraryElementByUid(c, signedInUser, cmd, nil)
}
// GetElementsForDashboard gets all connected elements for a specific dashboard.
func (l *LibraryElementService) GetElementsForDashboard(c context.Context, dashboardID int64) (map[string]model.LibraryElementDTO, error) {
return l.getElementsForDashboardID(c, dashboardID)
}
// ConnectElementsToDashboard connects elements to a specific dashboard.
func (l *LibraryElementService) ConnectElementsToDashboard(c context.Context, signedInUser identity.Requester, elementUIDs []string, dashboardID int64) error {
return l.connectElementsToDashboardID(c, signedInUser, elementUIDs, dashboardID)
}
// DisconnectElementsFromDashboard disconnects elements from a specific dashboard.
func (l *LibraryElementService) DisconnectElementsFromDashboard(c context.Context, dashboardID int64) error {
return l.disconnectElementsFromDashboardID(c, dashboardID)
}
// DeleteLibraryElementsInFolder deletes all elements for a specific folder.
func (l *LibraryElementService) DeleteLibraryElementsInFolder(c context.Context, signedInUser identity.Requester, folderUID string) error {
return l.deleteLibraryElementsInFolderUID(c, signedInUser, folderUID)

View file

@ -50,6 +50,7 @@ func TestIntegration_DeleteLibraryElement(t *testing.T) {
scenarioWithPanel(t, "When an admin tries to delete a library panel that is connected, it should fail",
func(t *testing.T, sc scenarioContext) {
sc.defaultGetDashByLP.Unset()
sc.dashboardSvc.On("GetDashboardsByLibraryPanelUID", mock.Anything, mock.Anything, mock.Anything).Return([]*dashboards.DashboardRef{
{
UID: "dash-1",
@ -62,23 +63,9 @@ func TestIntegration_DeleteLibraryElement(t *testing.T) {
require.Equal(t, 403, resp.Status())
})
scenarioWithPanel(t, "When an admin tries to delete a library panel with a stale connection table entry, it should succeed",
func(t *testing.T, sc scenarioContext) {
// Write a stale row to library_element_connection (simulates pre-unified-storage state)
// nolint:staticcheck
err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 9999999)
require.NoError(t, err)
// The search service says there are no real connections — deletion should succeed
sc.dashboardSvc.On("GetDashboardsByLibraryPanelUID", mock.Anything, mock.Anything, mock.Anything).Return([]*dashboards.DashboardRef{}, nil)
sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID})
resp := sc.service.deleteHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
})
scenarioWithPanel(t, "When a non-admin user cannot see a connected dashboard, deletion should still be blocked",
func(t *testing.T, sc scenarioContext) {
sc.defaultGetDashByLP.Unset()
// Downgrade user to Editor, so they can delete library panels but cannot see all folders/dashboards
sc.reqContext.OrgRole = org.RoleEditor

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
@ -129,72 +130,6 @@ func TestIntegration_GetLibraryElement(t *testing.T) {
require.NoError(t, err)
})
scenarioWithPanel(t, "When an admin tries to get a connected library panel, it should succeed and return correct connected dashboards",
func(t *testing.T, sc scenarioContext) {
err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 1)
require.NoError(t, err)
expected := func(res libraryElementResult) libraryElementResult {
return libraryElementResult{
Result: libraryElement{
ID: 1,
OrgID: 1,
FolderID: 1, // nolint:staticcheck
FolderUID: sc.folder.UID,
UID: res.Result.UID,
Name: "Text - Library Panel",
Kind: int64(model.PanelElement),
Type: "text",
Description: "A description",
Model: map[string]any{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"id": float64(1),
"title": "Text - Library Panel",
"type": "text",
},
Version: 1,
Meta: model.LibraryElementDTOMeta{
FolderName: sc.folder.Title,
FolderUID: sc.folder.UID,
ConnectedDashboards: 1,
Created: res.Result.Meta.Created,
Updated: res.Result.Meta.Updated,
CreatedBy: model.LibraryElementDTOMetaUser{
Id: 1,
Name: userInDbName,
AvatarUrl: userInDbAvatar,
},
UpdatedBy: model.LibraryElementDTOMetaUser{
Id: 1,
Name: userInDbName,
AvatarUrl: userInDbAvatar,
},
},
},
}
}
sc.reqContext.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersRead] = []string{dashboards.ScopeFoldersAll}
// by uid
sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID})
resp := sc.service.getHandler(sc.reqContext)
result := validateAndUnMarshalResponse(t, resp)
if diff := cmp.Diff(expected(result), result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
// by name
sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":name": sc.initialResult.Result.Name})
resp = sc.service.getByNameHandler(sc.reqContext)
arrayResult := validateAndUnMarshalArrayResponse(t, resp)
if diff := cmp.Diff(libraryElementArrayResult{Result: []libraryElement{expected(result).Result}}, arrayResult, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithPanel(t, "When an admin tries to get a library panel that exists in an other org, it should fail",
func(t *testing.T, sc scenarioContext) {
sc.reqContext.OrgID = 2
@ -210,4 +145,23 @@ func TestIntegration_GetLibraryElement(t *testing.T) {
resp = sc.service.getByNameHandler(sc.reqContext)
require.Equal(t, 404, resp.Status())
})
scenarioWithPanel(t, "When a library panel has connected dashboards, connectedDashboards should reflect the count",
func(t *testing.T, sc scenarioContext) {
sc.defaultGetDashByLP.Unset()
sc.dashboardSvc.On("GetDashboardsByLibraryPanelUID", mock.Anything, mock.Anything, mock.Anything).Return([]*dashboards.DashboardRef{
{UID: "dash-1", ID: 10},
{UID: "dash-2", ID: 20},
}, nil)
sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID})
resp := sc.service.getHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryElementResult
err := json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
require.Equal(t, int64(2), result.Result.Meta.ConnectedDashboards,
"connectedDashboards should match the number of dashboards returned by unified search")
})
}

View file

@ -53,10 +53,12 @@ func TestIntegration_DeleteLibraryPanelsInFolder(t *testing.T) {
scenarioWithPanel(t, "When an admin tries to delete a folder that contains connected library elements, it should fail",
func(t *testing.T, sc scenarioContext) {
err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 1)
require.NoError(t, err)
sc.defaultGetDashByLP.Unset()
sc.dashboardSvc.On("GetDashboardsByLibraryPanelUID", mock.Anything, mock.Anything, mock.Anything).Return([]*dashboards.DashboardRef{
{UID: "connected-dash", ID: 1},
}, nil)
err = sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.UID)
err := sc.service.DeleteLibraryElementsInFolder(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, sc.folder.UID)
require.EqualError(t, err, model.ErrFolderHasConnectedLibraryElements.Error())
})
@ -101,18 +103,11 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) {
scenarioWithPanel(t, "When an admin tries to get connections of library panel, it should succeed and return correct result",
func(t *testing.T, sc scenarioContext) {
err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 1)
require.NoError(t, err)
// add a connection where the dashboard doesn't exist. Shouldn't be returned in the list
err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 99999999)
require.NoError(t, err)
var expected = func(res model.LibraryElementConnectionsResponse) model.LibraryElementConnectionsResponse {
return model.LibraryElementConnectionsResponse{
Result: []model.LibraryElementConnectionDTO{
{
ID: sc.initialResult.Result.ID,
ID: res.Result[0].ID, // nolint:staticcheck
Kind: sc.initialResult.Result.Kind,
ElementID: 1,
ConnectionID: 1,
@ -127,6 +122,7 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) {
}
}
sc.defaultGetDashByLP.Unset()
sc.dashboardSvc.On("GetDashboardsByLibraryPanelUID", mock.Anything, mock.Anything, mock.Anything).Return([]*dashboards.DashboardRef{
{
ID: 1,
@ -142,33 +138,6 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithPanel(t, "When an admin tries to create a connection with an element that exists, but the original folder does not, it should still succeed",
func(t *testing.T, sc scenarioContext) {
b, err := json.Marshal(map[string]string{"test": "test"})
require.NoError(t, err)
newFolder := createFolder(t, sc, "NewFolder", sc.folderSvc)
sc.reqContext.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersRead] = []string{dashboards.ScopeFoldersAll}
sc.reqContext.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersDelete] = []string{dashboards.ScopeFoldersAll}
_, err = sc.service.CreateElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, model.CreateLibraryElementCommand{
FolderID: newFolder.ID, // nolint:staticcheck
FolderUID: &newFolder.UID,
Name: "Testing Library Panel With Deleted Folder",
Kind: 1,
Model: b,
UID: "panel-with-deleted-folder",
})
require.NoError(t, err)
err = sc.service.folderService.Delete(sc.reqContext.Req.Context(), &folder.DeleteFolderCommand{
UID: newFolder.UID,
OrgID: sc.reqContext.OrgID,
SignedInUser: sc.reqContext.SignedInUser,
})
require.NoError(t, err)
err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 1)
require.NoError(t, err)
})
}
type libraryElement struct {
@ -232,16 +201,17 @@ func getCreateCommandWithModel(folderID int64, folderUID, name string, kind mode
}
type scenarioContext struct {
ctx *web.Context
service *LibraryElementService
reqContext *contextmodel.ReqContext
user user.SignedInUser
folder *folder.Folder
initialResult libraryElementResult
sqlStore db.DB
log log.Logger
folderSvc *foldertest.FakeService
dashboardSvc *dashboards.FakeDashboardService
ctx *web.Context
service *LibraryElementService
reqContext *contextmodel.ReqContext
user user.SignedInUser
folder *folder.Folder
initialResult libraryElementResult
sqlStore db.DB
log log.Logger
folderSvc *foldertest.FakeService
dashboardSvc *dashboards.FakeDashboardService
defaultGetDashByLP *mock.Call
}
func createFolder(t *testing.T, sc scenarioContext, title string, folderSvc *foldertest.FakeService) *folder.Folder {
@ -388,6 +358,8 @@ func setupTestScenario(t *testing.T) scenarioContext {
_, err = usrSvc.Create(context.Background(), &cmd)
require.NoError(t, err)
defaultGetDashByLP := dashService.On("GetDashboardsByLibraryPanelUID", mock.Anything, mock.Anything, mock.Anything).Maybe().Return([]*dashboards.DashboardRef{}, nil)
sc := scenarioContext{
user: usr,
ctx: &webCtx,
@ -397,8 +369,9 @@ func setupTestScenario(t *testing.T) scenarioContext {
Context: &webCtx,
SignedInUser: &usr,
},
folderSvc: folderSvc,
dashboardSvc: dashService,
folderSvc: folderSvc,
dashboardSvc: dashService,
defaultGetDashByLP: defaultGetDashByLP,
}
sc.folder = createFolder(t, sc, "ScenarioFolder", folderSvc)

View file

@ -6,12 +6,6 @@ import (
"time"
)
type LibraryConnectionKind int
const (
Dashboard LibraryConnectionKind = iota + 1
)
// LibraryElement is the model for library element definitions.
type LibraryElement struct {
ID int64 `xorm:"pk autoincr 'id'"`
@ -101,29 +95,6 @@ type LibraryElementDTOMeta struct {
UpdatedBy LibraryElementDTOMetaUser `json:"updatedBy"`
}
// libraryElementConnection is the model for library element connections.
type LibraryElementConnection struct {
ID int64 `xorm:"pk autoincr 'id'"`
ElementID int64 `xorm:"element_id"`
Kind int64 `xorm:"kind"`
ConnectionID int64 `xorm:"connection_id"`
Created time.Time
CreatedBy int64
}
// libraryElementConnectionWithMeta is the model for library element connections with meta.
type LibraryElementConnectionWithMeta struct {
ID int64 `xorm:"pk autoincr 'id'"`
ElementID int64 `xorm:"element_id"`
Kind int64 `xorm:"kind"`
ConnectionID int64 `xorm:"connection_id"`
ConnectionUID string `xorm:"connection_uid"`
Created time.Time
CreatedBy int64
CreatedByName string
CreatedByEmail string
}
type LibraryElementDTOMetaUser struct {
Id int64 `json:"id"`
Name string `json:"name"`

View file

@ -41,7 +41,6 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout
// Service is a service for operating on library panels.
type Service interface {
ConnectLibraryPanelsForDashboard(c context.Context, signedInUser identity.Requester, dash *dashboards.Dashboard) error
ImportLibraryPanelsForDashboard(c context.Context, signedInUser identity.Requester, libraryPanels *simplejson.Json, panels []any, folderID int64, folderUID string) error
}
@ -62,73 +61,10 @@ type LibraryPanelService struct {
var _ Service = (*LibraryPanelService)(nil)
// ConnectLibraryPanelsForDashboard loops through all panels in dashboard JSON and connects any library panels to the dashboard.
func (lps *LibraryPanelService) ConnectLibraryPanelsForDashboard(c context.Context, signedInUser identity.Requester, dash *dashboards.Dashboard) error {
var panels []any
isV2 := dash.Data.Get("elements").Interface() != nil
if isV2 {
elementsMap := dash.Data.Get("elements").MustMap()
panels = make([]any, 0, len(elementsMap))
for _, element := range elementsMap {
panels = append(panels, element)
}
} else {
panels = dash.Data.Get("panels").MustArray()
}
libraryPanels := make(map[string]string)
err := connectLibraryPanelsRecursively(c, panels, libraryPanels, isV2)
if err != nil {
return err
}
elementUIDs := make([]string, 0, len(libraryPanels))
for libraryPanel := range libraryPanels {
elementUIDs = append(elementUIDs, libraryPanel)
}
return lps.LibraryElementService.ConnectElementsToDashboard(c, signedInUser, elementUIDs, dash.ID)
}
func isLibraryPanelOrRow(panel *simplejson.Json, panelType string) bool {
return panel.Interface() != nil || panelType == "row"
}
func connectLibraryPanelsRecursively(c context.Context, panels []any, libraryPanels map[string]string, isV2 bool) error {
for _, panel := range panels {
panelAsJSON := simplejson.NewFromAny(panel)
libraryPanel := panelAsJSON.Get("libraryPanel")
if isV2 {
libraryPanel = panelAsJSON.Get("spec").Get("libraryPanel")
}
panelType := panelAsJSON.Get("type").MustString()
if !isLibraryPanelOrRow(libraryPanel, panelType) {
continue
}
// we have a row
if panelType == "row" {
rowPanels := panelAsJSON.Get("panels").MustArray()
err := connectLibraryPanelsRecursively(c, rowPanels, libraryPanels, isV2)
if err != nil {
return err
}
continue
}
// we have a library panel
UID := libraryPanel.Get("uid").MustString()
if len(UID) == 0 {
return errLibraryPanelHeaderUIDMissing
}
_, exists := libraryPanels[UID]
if !exists {
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 context.Context, signedInUser identity.Requester, libraryPanels *simplejson.Json, panels []any, folderID int64, folderUID string) error {
return importLibraryPanelsRecursively(c, lps.LibraryElementService, signedInUser, libraryPanels, panels, folderID, folderUID)

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/routing"
@ -39,308 +40,9 @@ func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestIntegrationConnectLibraryPanelsForDashboard(t *testing.T) {
func TestIntegrationLibraryPanelFolderOperations(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with a library panel, it should connect the two",
func(t *testing.T, sc scenarioContext) {
dashJSON := map[string]any{
"panels": []any{
map[string]any{
"id": int64(1),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]any{
"id": int64(2),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]any{
"uid": sc.initialResult.Result.UID,
},
"title": "Text - Library Panel",
"type": "text",
},
},
}
dash := dashboards.Dashboard{
Title: "Testing ConnectLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc, &dash)
err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB)
require.NoError(t, err)
elements, err := sc.elementService.GetElementsForDashboard(sc.ctx, dashInDB.ID)
require.NoError(t, err)
require.Len(t, elements, 1)
require.Equal(t, sc.initialResult.Result.UID, elements[sc.initialResult.Result.UID].UID)
})
scenarioWithLibraryPanel(t, "When an admin tries to store a V2 dashboard with a library panel, it should connect the two",
func(t *testing.T, sc scenarioContext) {
dashJSON := map[string]any{
"elements": []any{
map[string]any{
"kind": "Panel",
"spec": map[string]any{
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]any{
"uid": sc.initialResult.Result.UID,
},
},
},
},
}
dash := dashboards.Dashboard{
Title: "Testing ConnectLibraryPanelsForDashboard for V2 dashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc, &dash)
err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB)
require.NoError(t, err)
})
scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with library panels inside and outside of rows, it should connect all",
func(t *testing.T, sc scenarioContext) {
cmd := model.CreateLibraryElementCommand{
Name: "Outside row",
Model: []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 1,
"title": "Text - Library Panel",
"type": "text",
"description": "A description"
}
`),
Kind: int64(model.PanelElement),
FolderUID: &sc.folder.UID,
}
outsidePanel, err := sc.elementService.CreateElement(sc.ctx, sc.user, cmd)
require.NoError(t, err)
dashJSON := map[string]any{
"panels": []any{
map[string]any{
"id": int64(1),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]any{
"collapsed": true,
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 0,
"y": 6,
},
"id": int64(2),
"type": "row",
"panels": []any{
map[string]any{
"id": int64(3),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 0,
"y": 7,
},
},
map[string]any{
"id": int64(4),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 6,
"y": 13,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]any{
"uid": sc.initialResult.Result.UID,
},
"title": "Inside row",
"type": "text",
},
},
},
map[string]any{
"id": int64(5),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 0,
"y": 19,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]any{
"uid": outsidePanel.UID,
},
"title": "Outside row",
"type": "text",
},
},
}
dash := dashboards.Dashboard{
Title: "Testing ConnectLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc, &dash)
err = sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB)
require.NoError(t, err)
elements, err := sc.elementService.GetElementsForDashboard(sc.ctx, dashInDB.ID)
require.NoError(t, err)
require.Len(t, elements, 2)
require.Equal(t, sc.initialResult.Result.UID, elements[sc.initialResult.Result.UID].UID)
require.Equal(t, outsidePanel.UID, elements[outsidePanel.UID].UID)
})
scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with a library panel without uid, it should fail",
func(t *testing.T, sc scenarioContext) {
dashJSON := map[string]any{
"panels": []any{
map[string]any{
"id": int64(1),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]any{
"id": int64(2),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]any{
"name": sc.initialResult.Result.Name,
},
"title": "Text - Library Panel",
"type": "text",
},
},
}
dash := dashboards.Dashboard{
Title: "Testing ConnectLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc, &dash)
err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB)
require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error())
})
scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with unused/removed library panels, it should disconnect unused/removed library panels",
func(t *testing.T, sc scenarioContext) {
unused, err := sc.elementService.CreateElement(sc.ctx, sc.user, model.CreateLibraryElementCommand{
Name: "Unused Libray Panel",
Model: []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 4,
"title": "Unused Libray Panel",
"type": "text",
"description": "Unused description"
}
`),
Kind: int64(model.PanelElement),
FolderUID: &sc.folder.UID,
})
require.NoError(t, err)
dashJSON := map[string]any{
"panels": []any{
map[string]any{
"id": int64(1),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]any{
"id": int64(4),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]any{
"uid": unused.UID,
},
"title": "Unused Libray Panel",
"description": "Unused description",
},
},
}
dash := dashboards.Dashboard{
Title: "Testing ConnectLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc, &dash)
err = sc.elementService.ConnectElementsToDashboard(sc.ctx, sc.user, []string{sc.initialResult.Result.UID}, dashInDB.ID)
require.NoError(t, err)
panelJSON := []any{
map[string]any{
"id": int64(1),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]any{
"id": int64(2),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]any{
"uid": sc.initialResult.Result.UID,
},
"title": "Text - Library Panel",
"type": "text",
},
}
dashInDB.Data.Set("panels", panelJSON)
err = sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB)
require.NoError(t, err)
elements, err := sc.elementService.GetElementsForDashboard(sc.ctx, dashInDB.ID)
require.NoError(t, err)
require.Len(t, elements, 1)
require.Equal(t, sc.initialResult.Result.UID, elements[sc.initialResult.Result.UID].UID)
})
scenarioWithLibraryPanel(t, "It should return the correct count of library panels in a folder",
func(t *testing.T, sc scenarioContext) {
count, err := sc.lps.CountInFolders(context.Background(), sc.user.OrgID, []string{sc.folder.UID}, sc.user)
@ -731,16 +433,6 @@ func getExpected(t *testing.T, res model.LibraryElementDTO, UID string, name str
},
}
}
func createDashboard(t *testing.T, sc scenarioContext, dash *dashboards.Dashboard) *dashboards.Dashboard {
dash.ID = 1
dash.UID = "test-dashboard-uid"
dash.Created = time.Now()
dash.Updated = time.Now()
dash.Version = 1
return dash
}
func scenarioWithLibraryPanel(t *testing.T, desc string, fn func(t *testing.T, sc scenarioContext)) {
t.Helper()
@ -847,6 +539,8 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
_, err = usrSvc.Create(context.Background(), &cmd)
require.NoError(t, err)
mockDashboardService.On("GetDashboardsByLibraryPanelUID", mock.Anything, mock.Anything, mock.Anything).Maybe().Return([]*dashboards.DashboardRef{}, nil)
sc := scenarioContext{
user: usr,
ctx: ctx,