Library Panels: Modify connection api endpoint to be compatible with unified storage (#107088)

This commit is contained in:
Stephanie Hingtgen
2025-06-25 22:21:56 +00:00
committed by GitHub
parent 3687767709
commit 79fe8a9902
24 changed files with 621 additions and 50 deletions
+3
View File
@@ -42,6 +42,7 @@ type DashboardService interface {
UnstructuredToLegacyDashboard(ctx context.Context, item *unstructured.Unstructured, orgID int64) (*Dashboard, error)
ValidateDashboardRefreshInterval(minRefreshInterval string, targetRefreshInterval string) error
ValidateBasicDashboardProperties(title string, uid string, message string) error
GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*DashboardRef, error)
}
type PermissionsRegistrationService interface {
@@ -104,4 +105,6 @@ type Store interface {
DeleteDashboardsInFolders(ctx context.Context, request *DeleteDashboardsInFolderRequest) error
GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*Dashboard, error)
GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*DashboardRef, error)
}
@@ -94,6 +94,36 @@ func (_m *FakeDashboardService) CountDashboardsInOrg(ctx context.Context, orgID
return r0, r1
}
// GetDashboardsByLibraryPanelUID provides a mock function with given fields: ctx, libraryPanelUID, orgID
func (_m *FakeDashboardService) GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*DashboardRef, error) {
ret := _m.Called(ctx, libraryPanelUID, orgID)
if len(ret) == 0 {
panic("no return value specified for GetDashboardsByLibraryPanelUID")
}
var r0 []*DashboardRef
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, int64) ([]*DashboardRef, error)); ok {
return rf(ctx, libraryPanelUID, orgID)
}
if rf, ok := ret.Get(0).(func(context.Context, string, int64) []*DashboardRef); ok {
r0 = rf(ctx, libraryPanelUID, orgID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*DashboardRef)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, int64) error); ok {
r1 = rf(ctx, libraryPanelUID, orgID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CountInFolders provides a mock function with given fields: ctx, orgID, folderUIDs, user
func (_m *FakeDashboardService) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) (int64, error) {
ret := _m.Called(ctx, orgID, folderUIDs, user)
@@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
@@ -70,6 +71,33 @@ func (d *dashboardStore) emitEntityEvent() bool {
return d.features != nil && d.features.IsEnabledGlobally(featuremgmt.FlagPanelTitleSearch)
}
func (d *dashboardStore) GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*dashboards.DashboardRef, error) {
ctx, span := tracer.Start(ctx, "dashboards.database.GetDashboardsByLibraryPanelUID")
defer span.End()
connectedDashboards := make([]*dashboards.DashboardRef, 0)
recursiveQueriesAreSupported, err := d.store.RecursiveQueriesAreSupported()
if err != nil {
return nil, err
}
err = d.store.WithDbSession(ctx, func(session *db.Session) error {
builder := db.NewSqlBuilder(d.cfg, d.features, d.store.GetDialect(), recursiveQueriesAreSupported)
builder.Write("SELECT d.*")
builder.Write(" FROM " + model.LibraryElementConnectionTableName + " AS lec")
builder.Write(" INNER JOIN " + model.LibraryElementTableName + " AS le ON lec.element_id = le.id")
builder.Write(" INNER JOIN dashboard AS d ON lec.connection_id = d.id")
builder.Write(` WHERE le.uid=? AND le.org_id=?`, libraryPanelUID, orgID)
if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&connectedDashboards); err != nil {
return err
}
return nil
})
return connectedDashboards, err
}
func (d *dashboardStore) ValidateDashboardBeforeSave(ctx context.Context, dash *dashboards.Dashboard, overwrite bool) (bool, error) {
ctx, span := tracer.Start(ctx, "dashboards.database.ValidateDashboardBeforesave")
defer span.End()
@@ -21,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
libmodel "github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/search/sort"
@@ -1145,6 +1146,94 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) {
}
}
func TestIntegrationGetDashboardsByLibraryPanelUID(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
sqlStore, cfg := db.InitTestDBWithCfg(t)
dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore))
require.NoError(t, err)
t.Run("Should return dashboards connected to a library panel", func(t *testing.T) {
libraryElement := insertTestLibraryElement(t, sqlStore, "test-library-panel", 1, "", "Test Library Panel")
dash1 := insertTestDashboard(t, dashboardStore, "Dashboard 1", 1, 0, "", false, "prod")
dash2 := insertTestDashboard(t, dashboardStore, "Dashboard 2", 1, 0, "", false, "webapp")
dash3 := insertTestDashboard(t, dashboardStore, "Dashboard 3", 1, 0, "", false, "backend")
// connect all but the last one
insertTestLibraryElementConnection(t, sqlStore, libraryElement.ID, dash1.ID)
insertTestLibraryElementConnection(t, sqlStore, libraryElement.ID, dash2.ID)
connectedDashboards, err := dashboardStore.GetDashboardsByLibraryPanelUID(context.Background(), libraryElement.UID, 1)
require.NoError(t, err)
require.Len(t, connectedDashboards, 2)
uids := []string{connectedDashboards[0].UID, connectedDashboards[1].UID}
require.Contains(t, uids, dash1.UID)
require.Contains(t, uids, dash2.UID)
require.NotContains(t, uids, dash3.UID)
})
t.Run("Returns nothing when library panel has no connections", func(t *testing.T) {
libraryElement := insertTestLibraryElement(t, sqlStore, "empty-library-panel", 1, "", "Empty Library Panel")
connectedDashboards, err := dashboardStore.GetDashboardsByLibraryPanelUID(context.Background(), libraryElement.UID, 1)
require.NoError(t, err)
require.Len(t, connectedDashboards, 0)
})
t.Run("Returns nothing when library panel does not exist", func(t *testing.T) {
connectedDashboards, err := dashboardStore.GetDashboardsByLibraryPanelUID(context.Background(), "non-existent-uid", 1)
require.NoError(t, err)
require.Len(t, connectedDashboards, 0)
})
}
func insertTestLibraryElement(t *testing.T, sqlStore db.DB, uid string, orgID int64, folderUID string, name string) *libmodel.LibraryElement {
t.Helper()
element := &libmodel.LibraryElement{
OrgID: orgID,
FolderUID: folderUID,
UID: uid,
Name: name,
Kind: 1,
Type: "text",
Description: "Test library element",
Model: []byte(`{"type": "text", "content": "test"}`),
Version: 1,
Created: time.Now(),
Updated: time.Now(),
CreatedBy: 1,
UpdatedBy: 1,
}
err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
_, err := sess.Insert(element)
return err
})
require.NoError(t, err)
return element
}
func insertTestLibraryElementConnection(t *testing.T, sqlStore db.DB, elementID int64, dashboardID int64) {
t.Helper()
connection := &libmodel.LibraryElementConnection{
ElementID: elementID,
Kind: 1,
ConnectionID: dashboardID,
Created: time.Now(),
CreatedBy: 1,
}
err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
_, err := sess.Insert(connection)
return err
})
require.NoError(t, err)
}
func insertTestRule(t *testing.T, sqlStore db.DB, foderOrgID int64, folderUID string) {
err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
type alertQuery struct {
+2
View File
@@ -299,6 +299,8 @@ type DashboardRef struct {
UID string `xorm:"uid"`
Slug string
FolderUID string `xorm:"folder_uid"`
// Deprecated: use UID instead
ID int64 `xorm:"id"`
}
type GetDashboardRefByIDQuery struct {
@@ -60,6 +60,7 @@ import (
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/search"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/retryer"
)
@@ -480,6 +481,43 @@ func (dr *DashboardServiceImpl) Count(ctx context.Context, scopeParams *quota.Sc
return dr.dashboardStore.Count(ctx, scopeParams)
}
func (dr *DashboardServiceImpl) GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*dashboards.DashboardRef, error) {
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) && dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesLibraryPanels) {
res, err := dr.k8sclient.Search(ctx, orgID, &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Fields: []*resourcepb.Requirement{
{
Key: search.DASHBOARD_LIBRARY_PANEL_REFERENCE,
Operator: string(selection.Equals),
Values: []string{libraryPanelUID},
},
},
},
Limit: listAllDashboardsLimit,
})
if err != nil {
return nil, err
}
results, err := dashboardsearch.ParseResults(res, 0)
if err != nil {
return nil, err
}
dashes := make([]*dashboards.DashboardRef, 0, len(results.Hits))
for _, row := range results.Hits {
dashes = append(dashes, &dashboards.DashboardRef{
UID: row.Name,
FolderUID: row.Folder,
ID: row.Field.GetNestedInt64(resource.SEARCH_FIELD_LEGACY_ID), // nolint:staticcheck
})
}
return dashes, nil
}
return dr.dashboardStore.GetDashboardsByLibraryPanelUID(ctx, libraryPanelUID, orgID)
}
func (dr *DashboardServiceImpl) CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error) {
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) {
resp, err := dr.k8sclient.GetStats(ctx, orgID)
@@ -46,6 +46,7 @@ import (
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/search"
)
func TestDashboardService(t *testing.T) {
@@ -2918,3 +2919,106 @@ func createTestUnstructuredDashboard(uid, title string, resourceVersion string)
},
}
}
func TestGetDashboardsByLibraryPanelUID(t *testing.T) {
fakeStore := dashboards.FakeDashboardStore{}
fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
defer fakeStore.AssertExpectations(t)
k8sCliMock := new(client.MockK8sHandler)
folderSvc := foldertest.NewFakeService()
service := &DashboardServiceImpl{
cfg: setting.NewCfg(),
log: log.New("test.logger"),
dashboardStore: &fakeStore,
folderService: folderSvc,
ac: actest.FakeAccessControl{ExpectedEvaluate: true},
features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders, featuremgmt.FlagKubernetesLibraryPanels),
publicDashboardService: fakePublicDashboardService,
k8sclient: k8sCliMock,
}
searchResponse := &resourcepb.ResourceSearchResponse{
TotalHits: 3,
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{Name: resource.SEARCH_FIELD_TITLE, Type: resourcepb.ResourceTableColumnDefinition_STRING},
{Name: resource.SEARCH_FIELD_FOLDER, Type: resourcepb.ResourceTableColumnDefinition_STRING},
{Name: resource.SEARCH_FIELD_TAGS, Type: resourcepb.ResourceTableColumnDefinition_STRING},
{Name: resource.SEARCH_FIELD_LEGACY_ID, Type: resourcepb.ResourceTableColumnDefinition_INT64},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "dashboard1",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("Dashboard 1"),
[]byte("folder1"),
[]byte("[]"),
[]byte("1"),
},
},
{
Key: &resourcepb.ResourceKey{
Name: "dashboard2",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("Dashboard 2"),
[]byte("folder2"),
[]byte("[]"),
[]byte("2"),
},
},
{
Key: &resourcepb.ResourceKey{
Name: "dashboard3",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("Dashboard 3"),
[]byte(""),
[]byte("[]"),
[]byte("3"),
},
},
},
},
}
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool {
return len(req.Options.Fields) == 1 &&
req.Options.Fields[0].Key == search.DASHBOARD_LIBRARY_PANEL_REFERENCE &&
req.Options.Fields[0].Values[0] == "test-library-panel"
})).Return(searchResponse, nil).Once()
results, err := service.GetDashboardsByLibraryPanelUID(context.Background(), "test-library-panel", 1)
require.NoError(t, err)
require.Len(t, results, 3)
resultMap := make(map[string]*dashboards.DashboardRef)
for _, result := range results {
resultMap[result.UID] = result
}
expectedDashboards := map[string]struct {
folderUID string
id int64
}{
"dashboard1": {folderUID: "folder1", id: 1},
"dashboard2": {folderUID: "folder2", id: 2},
"dashboard3": {folderUID: "", id: 3},
}
for uid, expected := range expectedDashboards {
result, exists := resultMap[uid]
require.True(t, exists, "Expected dashboard %s not found", uid)
require.Equal(t, expected.folderUID, result.FolderUID, "Folder UID mismatch for %s", uid)
require.Equal(t, expected.id, result.ID, "ID mismatch for %s", uid) // nolint:staticcheck
}
k8sCliMock.AssertExpectations(t)
}
+30
View File
@@ -628,6 +628,36 @@ func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64
return r0
}
// GetDashboardsByLibraryPanelUID provides a mock function with given fields: ctx, libraryPanelUID, orgID
func (_m *FakeDashboardStore) GetDashboardsByLibraryPanelUID(ctx context.Context, libraryPanelUID string, orgID int64) ([]*DashboardRef, error) {
ret := _m.Called(ctx, libraryPanelUID, orgID)
if len(ret) == 0 {
panic("no return value specified for GetDashboardsByLibraryPanelUID")
}
var r0 []*DashboardRef
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, int64) ([]*DashboardRef, error)); ok {
return rf(ctx, libraryPanelUID, orgID)
}
if rf, ok := ret.Get(0).(func(context.Context, string, int64) []*DashboardRef); ok {
r0 = rf(ctx, libraryPanelUID, orgID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*DashboardRef)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, int64) error); ok {
r1 = rf(ctx, libraryPanelUID, orgID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ValidateDashboardBeforeSave provides a mock function with given fields: ctx, dashboard, overwrite
func (_m *FakeDashboardStore) ValidateDashboardBeforeSave(ctx context.Context, dashboard *Dashboard, overwrite bool) (bool, error) {
ret := _m.Called(ctx, dashboard, overwrite)