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
@@ -251,7 +251,7 @@ Content-Type: application/json
{
"result": [
{
"id": 148,
"id": 148, // Deprecated: will be removed in the future.
"kind": 1,
"elementId": 25,
"connectionId": 527,
@@ -305,6 +305,10 @@ export interface FeatureToggles {
*/
kubernetesSnapshots?: boolean;
/**
* Routes library panel requests from /api to the /apis endpoint
*/
kubernetesLibraryPanels?: boolean;
/**
* Use the kubernetes API in the frontend for dashboards
*/
kubernetesDashboards?: boolean;
@@ -229,6 +229,12 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
return nil, fmt.Errorf("only one repo name is supported")
}
query.ManagerIdentity = vals[0]
case unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE:
if len(vals) != 1 {
return nil, fmt.Errorf("only one library panel uid is supported")
}
return c.getLibraryPanelConnections(ctx, user, vals[0], req.Options.Key.Namespace)
case resource.SEARCH_FIELD_TITLE_PHRASE:
if len(vals) != 1 {
return nil, fmt.Errorf("only one title supported")
@@ -356,6 +362,34 @@ func getResourceKey(item *dashboards.DashboardSearchProjection, namespace string
}
}
// retrieves all the dashboards that are connected to the given library panel
func (c *DashboardSearchClient) getLibraryPanelConnections(ctx context.Context, user identity.Requester, libraryElementUID, namespace string) (*resourcepb.ResourceSearchResponse, error) {
connections, err := c.dashboardStore.GetDashboardsByLibraryPanelUID(ctx, libraryElementUID, user.GetOrgID())
if err != nil {
return nil, err
}
columns := c.getColumns("", &dashboards.FindPersistedDashboardsQuery{})
list := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: columns,
},
}
for _, dashboard := range connections {
cells := c.createCommonCells("", dashboard.FolderUID, dashboard.ID, nil) // nolint:staticcheck
list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{
Key: getResourceKey(&dashboards.DashboardSearchProjection{
UID: dashboard.UID,
}, namespace),
Cells: cells,
})
}
list.TotalHits = int64(len(list.Results.Rows))
return list, nil
}
func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest, _ ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) {
info, err := claims.ParseNamespace(req.Namespace)
if err != nil {
@@ -572,6 +572,72 @@ func TestDashboardSearchClient_Search(t *testing.T) {
require.Equal(t, []byte(strconv.FormatInt(100, 10)), resp.Results.Rows[0].Cells[i]) // views should be set to 100
mockStore.AssertExpectations(t)
})
t.Run("Should search dashboards based on what is connected to a library panel", func(t *testing.T) {
mockStore.On("GetDashboardsByLibraryPanelUID", mock.Anything, "test-library-panel", int64(2)).Return([]*dashboards.DashboardRef{
{UID: "dashboard1", FolderUID: "folder1", ID: 1},
{UID: "dashboard2", FolderUID: "folder2", ID: 2},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{
{
Key: unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE,
Operator: "=",
Values: []string{"test-library-panel"},
},
},
},
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, int64(2), resp.TotalHits)
require.Len(t, resp.Results.Rows, 2)
dashboardNames := make([]string, len(resp.Results.Rows))
dashboardFolders := make([]string, len(resp.Results.Rows))
dashboardIDs := make([]string, len(resp.Results.Rows))
for i, row := range resp.Results.Rows {
dashboardNames[i] = row.Key.Name
dashboardFolders[i] = string(row.Cells[1])
dashboardIDs[i] = string(row.Cells[3])
}
require.Contains(t, dashboardNames, "dashboard1")
require.Contains(t, dashboardNames, "dashboard2")
require.Contains(t, dashboardFolders, "folder1")
require.Contains(t, dashboardFolders, "folder2")
require.Contains(t, dashboardIDs, "1")
require.Contains(t, dashboardIDs, "2")
mockStore.AssertExpectations(t)
for _, row := range resp.Results.Rows {
require.Equal(t, len(row.Cells), len(resp.Results.Columns))
}
})
t.Run("Only one library panel uid is supported", func(t *testing.T) {
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{
{
Key: unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE,
Operator: "=",
Values: []string{"panel1", "panel2"},
},
},
},
}
resp, err := client.Search(ctx, req)
require.Error(t, err)
require.Contains(t, err.Error(), "only one library panel uid is supported")
require.Nil(t, resp)
})
}
func TestParseSortName(t *testing.T) {
+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)
+7
View File
@@ -507,6 +507,13 @@ var (
Owner: grafanaAppPlatformSquad,
RequiresRestart: true, // changes the API routing
},
{
Name: "kubernetesLibraryPanels",
Description: "Routes library panel requests from /api to the /apis endpoint",
Stage: FeatureStageExperimental,
Owner: grafanaAppPlatformSquad,
RequiresRestart: true, // changes the API routing
},
{
Name: "kubernetesDashboards",
Description: "Use the kubernetes API in the frontend for dashboards",
+1
View File
@@ -65,6 +65,7 @@ enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,f
disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false
formatString,GA,@grafana/dataviz-squad,false,false,true
kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false
kubernetesLibraryPanels,experimental,@grafana/grafana-app-platform-squad,false,true,false
kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true
kubernetesClientDashboardsFolders,GA,@grafana/grafana-app-platform-squad,false,false,false
dashboardDisableSchemaValidationV1,experimental,@grafana/grafana-app-platform-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
65 disableClassicHTTPHistogram experimental @grafana/grafana-backend-services-squad false true false
66 formatString GA @grafana/dataviz-squad false false true
67 kubernetesSnapshots experimental @grafana/grafana-app-platform-squad false true false
68 kubernetesLibraryPanels experimental @grafana/grafana-app-platform-squad false true false
69 kubernetesDashboards experimental @grafana/grafana-app-platform-squad false false true
70 kubernetesClientDashboardsFolders GA @grafana/grafana-app-platform-squad false false false
71 dashboardDisableSchemaValidationV1 experimental @grafana/grafana-app-platform-squad false false false
+4
View File
@@ -271,6 +271,10 @@ const (
// Routes snapshot requests from /api to the /apis endpoint
FlagKubernetesSnapshots = "kubernetesSnapshots"
// FlagKubernetesLibraryPanels
// Routes library panel requests from /api to the /apis endpoint
FlagKubernetesLibraryPanels = "kubernetesLibraryPanels"
// FlagKubernetesDashboards
// Use the kubernetes API in the frontend for dashboards
FlagKubernetesDashboards = "kubernetesDashboards"
+13
View File
@@ -1621,6 +1621,19 @@
"hideFromAdminPage": true
}
},
{
"metadata": {
"name": "kubernetesLibraryPanels",
"resourceVersion": "1750714411267",
"creationTimestamp": "2025-06-23T21:33:31Z"
},
"spec": {
"description": "Routes library panel requests from /api to the /apis endpoint",
"stage": "experimental",
"codeowner": "@grafana/grafana-app-platform-squad",
"requiresRestart": true
}
},
{
"metadata": {
"name": "kubernetesSnapshots",
+63 -2
View File
@@ -2,17 +2,21 @@ package libraryelements
import (
"errors"
"fmt"
"hash/fnv"
"net/http"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/kinds/librarypanel"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/web"
)
@@ -274,9 +278,66 @@ func (l *LibraryElementService) patchHandler(c *contextmodel.ReqContext) respons
// 404: notFoundError
// 500: internalServerError
func (l *LibraryElementService) getConnectionsHandler(c *contextmodel.ReqContext) response.Response {
connections, err := l.getConnections(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
libraryPanelUID := web.Params(c.Req)[":uid"]
// make sure the library element exists
element, err := l.getLibraryElementByUid(c.Req.Context(), c.SignedInUser, model.GetLibraryElementCommand{
UID: libraryPanelUID,
})
if err != nil {
return l.toLibraryElementError(err, "Failed to get connections")
return l.toLibraryElementError(err, "Failed to get library element")
}
// now get all dashboards connected to this library element
dashboards, err := l.dashboardsService.GetDashboardsByLibraryPanelUID(c.Req.Context(), libraryPanelUID, c.GetOrgID())
if err != nil {
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 = append(connections, model.LibraryElementConnectionDTO{
ID: connectionID,
Kind: int64(model.PanelElement),
ElementID: element.ID,
ConnectionID: dashboard.ID, // nolint:staticcheck
ConnectionUID: dashboard.UID,
// returns the creation information of the library element, not the connection
CreatedBy: librarypanel.LibraryElementDTOMetaUser{
Id: element.Meta.CreatedBy.Id,
Name: element.Meta.CreatedBy.Name,
AvatarUrl: element.Meta.CreatedBy.AvatarUrl,
},
Created: element.Meta.Created,
})
}
return response.JSON(http.StatusOK, model.LibraryElementConnectionsResponse{Result: connections})
+12 -47
View File
@@ -712,66 +712,27 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU
return dto, err
}
// getConnections gets all connections for a Library Element.
func (l *LibraryElementService) getConnections(c context.Context, signedInUser identity.Requester, uid string) ([]model.LibraryElementConnectionDTO, error) {
connections := make([]model.LibraryElementConnectionDTO, 0)
// 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
}
err = l.SQLStore.WithDbSession(c, func(session *db.Session) error {
element, err := l.GetLibraryElement(c, signedInUser, session, uid)
if err != nil {
return err
}
var libraryElementConnections []model.LibraryElementConnectionWithMeta
builder := db.NewSqlBuilder(l.Cfg, l.features, l.SQLStore.GetDialect(), recursiveQueriesAreSupported)
builder.Write("SELECT lec.*, u1.login AS created_by_name, u1.email AS created_by_email")
builder.Write(" FROM " + model.LibraryElementConnectionTableName + " AS lec")
builder.Write(" LEFT JOIN " + l.SQLStore.GetDialect().Quote("user") + " AS u1 ON lec.created_by = u1.id")
builder.Write(` WHERE lec.element_id=?`, element.ID)
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
}
// getting all folders a user can see
fs, err := l.folderService.GetFolders(c, folder.GetFoldersQuery{OrgID: signedInUser.GetOrgID(), SignedInUser: signedInUser})
if err != nil {
return err
}
// Every signed in user can see the general folder. The general folder might have "general" or the empty string as its UID.
var folderUIDS = []string{"general", ""}
for _, f := range fs {
folderUIDS = append(folderUIDS, f.UID)
}
// 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 {
if !signedInUser.HasRole(org.RoleAdmin) {
if !contains(folderUIDS, element.FolderUID) {
continue
}
}
ds, err := l.dashboardsService.GetDashboardUIDByID(c, &dashboards.GetDashboardRefByIDQuery{ID: connection.ConnectionID})
if err != nil {
if errors.Is(err, dashboards.ErrDashboardNotFound) {
continue
}
return err
}
connections = append(connections, model.LibraryElementConnectionDTO{
ID: connection.ID,
Kind: connection.Kind,
ElementID: connection.ElementID,
ConnectionID: connection.ConnectionID,
ConnectionUID: ds.UID,
Created: connection.Created,
CreatedBy: librarypanel.LibraryElementDTOMetaUser{
Id: connection.CreatedBy,
Name: connection.CreatedByName,
AvatarUrl: dtos.GetGravatarUrl(l.Cfg, connection.CreatedByEmail),
},
})
connections[getConnectionKey(connection.ElementID, connection.ConnectionID)] = connection.ID
}
return nil
@@ -780,6 +741,10 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser i
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)
+13
View File
@@ -91,3 +91,16 @@ func (l *LibraryElementService) requireViewPermissionsOnFolder(ctx context.Conte
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)
if err != nil {
return err
}
if !canView {
return dashboards.ErrFolderAccessDenied
}
return nil
}
@@ -209,6 +209,80 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) {
}
})
scenarioWithPanel(t, "When a user tries to get connections of library panel, dashboards in inaccessible folders should not be returned",
func(t *testing.T, sc scenarioContext) {
accessibleFolder := createFolder(t, sc, "AccessibleFolder", sc.service.folderService)
inaccessibleFolder := createFolder(t, sc, "InAccessibleFolder", sc.service.folderService)
restrictedUser := user.SignedInUser{
UserID: 2,
Name: "Non-Admin User",
Login: "non-admin-user",
OrgID: sc.user.OrgID,
OrgRole: org.RoleViewer,
LastSeenAt: time.Now(),
Permissions: map[int64]map[string][]string{
sc.user.OrgID: {
dashboards.ActionFoldersRead: {
dashboards.ScopeFoldersProvider.GetResourceScopeUID(accessibleFolder.UID),
},
dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID("*")},
},
},
}
command := getCreatePanelCommand(accessibleFolder.ID, accessibleFolder.UID, "Accessible Library Panel") // nolint:staticcheck
sc.reqContext.Req.Body = mockRequestBody(command)
resp := sc.service.createHandler(sc.reqContext)
libraryElement := validateAndUnMarshalResponse(t, resp)
dashJSON := map[string]any{
"panels": []any{
map[string]any{
"id": int64(1),
"gridPos": map[string]any{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
"libraryPanel": map[string]any{
"uid": libraryElement.Result.UID,
"name": libraryElement.Result.Name,
},
},
},
}
accessibleDash := dashboards.Dashboard{
Title: "Accessible Dashboard",
Data: simplejson.NewFromAny(dashJSON),
}
// create the dashboard in the general folder, an accessible folder, and an inaccessible folder
dashInGeneral := createDashboard(t, sc.sqlStore, restrictedUser, &accessibleDash, 0, "")
err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{libraryElement.Result.UID}, dashInGeneral.ID)
require.NoError(t, err)
dashInAccessibleFolder := createDashboard(t, sc.sqlStore, restrictedUser, &accessibleDash, 0, accessibleFolder.UID)
err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{libraryElement.Result.UID}, dashInAccessibleFolder.ID)
require.NoError(t, err)
dashInInaccessibleFolder := createDashboard(t, sc.sqlStore, restrictedUser, &accessibleDash, 0, inaccessibleFolder.UID)
err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{libraryElement.Result.UID}, dashInInaccessibleFolder.ID)
require.NoError(t, err)
sc.reqContext.SignedInUser = &restrictedUser
sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": libraryElement.Result.UID})
// connections should return the general folder one and the accessible folder one
connectionsResp := sc.service.getConnectionsHandler(sc.reqContext)
var result = validateAndUnMarshalConnectionResponse(t, connectionsResp)
require.Len(t, result.Result, 2)
uids := []string{result.Result[0].ConnectionUID, result.Result[1].ConnectionUID}
require.Contains(t, uids, dashInGeneral.UID)
require.Contains(t, uids, dashInAccessibleFolder.UID)
require.NotContains(t, uids, dashInInaccessibleFolder.UID)
})
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"})
@@ -128,6 +128,7 @@ type LibraryElementConnectionWithMeta struct {
// LibraryElementConnectionDTO is the frontend DTO for element connections.
type LibraryElementConnectionDTO struct {
// Deprecated: this field will be removed in the future
ID int64 `json:"id"`
Kind int64 `json:"kind"`
ElementID int64 `json:"elementId"`
@@ -263,4 +264,5 @@ const (
PanelElement LibraryElementKind = iota + 1
)
const LibraryElementTableName = "library_element"
const LibraryElementConnectionTableName = "library_element_connection"
+1
View File
@@ -24,6 +24,7 @@ const DASHBOARD_LINK_COUNT = "link_count"
const DASHBOARD_PANEL_TYPES = "panel_types"
const DASHBOARD_DS_TYPES = "ds_types"
const DASHBOARD_TRANSFORMATIONS = "transformation"
const DASHBOARD_LIBRARY_PANEL_REFERENCE = "reference.LibraryPanel"
//------------------------------------------------------------
// The following fields are added in enterprise
+1
View File
@@ -16982,6 +16982,7 @@
"format": "int64"
},
"id": {
"description": "Deprecated: this field will be removed in the future",
"type": "integer",
"format": "int64"
},
+1
View File
@@ -7028,6 +7028,7 @@
"type": "integer"
},
"id": {
"description": "Deprecated: this field will be removed in the future",
"format": "int64",
"type": "integer"
},