LibraryPanels: Support CRUD via apiserver (#113035)

This commit is contained in:
Ryan McKinley
2025-10-31 08:14:38 +03:00
committed by GitHub
parent cf242def3a
commit 53aed34652
22 changed files with 617 additions and 159 deletions
+3 -3
View File
@@ -87,7 +87,7 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon
}
}
element, err := l.createLibraryElement(c.Req.Context(), c.SignedInUser, cmd)
element, err := l.CreateElement(c.Req.Context(), c.SignedInUser, cmd)
if err != nil {
return l.toLibraryElementError(err, "Failed to create library element")
}
@@ -124,7 +124,7 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon
// 404: notFoundError
// 500: internalServerError
func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) response.Response {
id, err := l.deleteLibraryElement(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
id, err := l.DeleteLibraryElement(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
if err != nil {
return l.toLibraryElementError(err, "Failed to delete library element")
}
@@ -252,7 +252,7 @@ func (l *LibraryElementService) patchHandler(c *contextmodel.ReqContext) respons
}
}
element, err := l.patchLibraryElement(c.Req.Context(), c.SignedInUser, cmd, web.Params(c.Req)[":uid"])
element, err := l.PatchLibraryElement(c.Req.Context(), c.SignedInUser, cmd, web.Params(c.Req)[":uid"])
if err != nil {
return l.toLibraryElementError(err, "Failed to update library element")
}
@@ -0,0 +1,59 @@
package libraryelements
import (
"encoding/json"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/util"
)
func ToCreateLibraryElementCommand(raw runtime.Object) (*model.CreateLibraryElementCommand, error) {
obj, err := utils.MetaAccessor(raw)
if err != nil {
return nil, err
}
folder := obj.GetFolder()
cmd := &model.CreateLibraryElementCommand{
UID: obj.GetName(),
FolderUID: &folder,
Kind: 1, // the only kind... LibraryPanel
Name: obj.FindTitle("library panel"),
}
if cmd.UID == "" {
if obj.GetGenerateName() == "" {
return nil, fmt.Errorf("expecting either name or generateName property")
}
cmd.UID = obj.GetGenerateName() + util.GenerateShortUID()
}
cmd.Model, err = toRawMessage(raw)
return cmd, err
}
func ToPatchLibraryElementCommand(raw runtime.Object) (*model.PatchLibraryElementCommand, error) {
obj, err := utils.MetaAccessor(raw)
if err != nil {
return nil, err
}
folder := obj.GetFolder()
cmd := &model.PatchLibraryElementCommand{
UID: obj.GetName(),
FolderUID: &folder,
Kind: 1, // the only kind... LibraryPanel
Name: obj.FindTitle("library panel"),
}
cmd.Model, err = toRawMessage(raw)
return cmd, err
}
func toRawMessage(raw runtime.Object) (json.RawMessage, error) {
switch obj := raw.(type) {
case *v0alpha1.LibraryPanel:
return json.Marshal(obj.Spec)
}
return nil, fmt.Errorf("unsupported library panel type: %T", raw)
}
@@ -0,0 +1,101 @@
package libraryelements
import (
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/utils/ptr"
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
)
func TestConversionsCommands(t *testing.T) {
cases := []struct {
name string
input runtime.Object
expectedCreate *model.CreateLibraryElementCommand
expectedPatch *model.PatchLibraryElementCommand
}{
{
name: "basic conversion",
input: &v0alpha1.LibraryPanel{
ObjectMeta: metav1.ObjectMeta{
Name: "uid",
Annotations: map[string]string{
utils.AnnoKeyFolder: "aaa",
},
},
Spec: v0alpha1.LibraryPanelSpec{
Type: "timeseries",
PluginVersion: "1.2.3",
Title: "title",
Description: "descr",
Options: common.Unstructured{
Object: map[string]any{
"hello": "options",
},
},
FieldConfig: common.Unstructured{
Object: map[string]any{
"hello": "fieldConfig",
},
},
PanelTitle: "panel title",
GridPos: v0alpha1.GridPos{
W: 1, H: 2, X: 3, Y: 4,
},
Transparent: true,
Links: []common.Unstructured{{
Object: map[string]any{
"link1": "hello",
},
}},
Datasource: &data.DataSourceRef{
UID: "uid",
Type: "ttt",
APIVersion: "v0alpha1",
},
},
},
expectedCreate: &model.CreateLibraryElementCommand{
FolderUID: ptr.To("aaa"),
UID: "uid",
Name: "title",
Kind: 1,
Model: json.RawMessage(`{"type":"timeseries","pluginVersion":"1.2.3","title":"title","panelTitle":"panel title","description":"descr","options":{"hello":"options"},"fieldConfig":{"hello":"fieldConfig"},"datasource":{"type":"ttt","uid":"uid","apiVersion":"v0alpha1"},"gridPos":{"w":1,"h":2,"x":3,"y":4},"transparent":true,"links":[{"link1":"hello"}]}`),
},
expectedPatch: &model.PatchLibraryElementCommand{
FolderUID: ptr.To("aaa"),
UID: "uid",
Name: "title",
Kind: 1,
Version: 0,
Model: json.RawMessage(`{"type":"timeseries","pluginVersion":"1.2.3","title":"title","panelTitle":"panel title","description":"descr","options":{"hello":"options"},"fieldConfig":{"hello":"fieldConfig"},"datasource":{"type":"ttt","uid":"uid","apiVersion":"v0alpha1"},"gridPos":{"w":1,"h":2,"x":3,"y":4},"transparent":true,"links":[{"link1":"hello"}]}`),
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
create, err := ToCreateLibraryElementCommand(tt.input)
require.NoError(t, err)
if diff := cmp.Diff(tt.expectedCreate, create); diff != "" {
require.FailNowf(t, "Create mismatch (-want +got):%s", diff)
}
patch, err := ToPatchLibraryElementCommand(tt.input)
require.NoError(t, err)
if diff := cmp.Diff(tt.expectedPatch, patch); diff != "" {
require.FailNowf(t, "Path mismatch (-want +got):%s", diff)
}
})
}
}
+4 -4
View File
@@ -110,7 +110,7 @@ func (l *LibraryElementService) GetLibraryElement(c context.Context, signedInUse
}
// createLibraryElement adds a library element.
func (l *LibraryElementService) createLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) {
func (l *LibraryElementService) CreateElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) {
if err := l.requireSupportedElementKind(cmd.Kind); err != nil {
return model.LibraryElementDTO{}, err
}
@@ -169,7 +169,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn
err = l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
allowed, err := l.AccessControl.Evaluate(c, signedInUser, ac.EvalPermission(ActionLibraryPanelsCreate, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID)))
if !allowed {
return fmt.Errorf("insufficient permissions for creating library panel in folder with UID %s", folderUID)
return fmt.Errorf("insufficient permissions for creating library panel in folder with UID: '%s'", folderUID)
}
if err != nil {
return err
@@ -216,7 +216,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn
}
// deleteLibraryElement deletes a library element.
func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error) {
func (l *LibraryElementService) DeleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error) {
var elementID int64
err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
element, err := l.GetLibraryElement(c, signedInUser, session, uid)
@@ -578,7 +578,7 @@ func (l *LibraryElementService) handleFolderIDPatches(ctx context.Context, eleme
}
// patchLibraryElement updates a Library Element.
func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error) {
func (l *LibraryElementService) PatchLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error) {
var dto model.LibraryElementDTO
if err := l.requireSupportedElementKind(cmd.Kind); err != nil {
return model.LibraryElementDTO{}, err
@@ -17,6 +17,16 @@ type LibraryElementService struct {
idCounter int64
}
// DeleteLibraryElement implements libraryelements.Service.
func (l *LibraryElementService) DeleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error) {
panic("unimplemented")
}
// PatchLibraryElement implements libraryelements.Service.
func (l *LibraryElementService) PatchLibraryElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error) {
panic("unimplemented")
}
var _ libraryelements.Service = (*LibraryElementService)(nil)
func (l *LibraryElementService) CreateElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) {
@@ -68,6 +78,11 @@ func (l *LibraryElementService) CreateElement(c context.Context, signedInUser id
return dto, nil
}
// PatchElement implements libraryelements.Service.
func (l *LibraryElementService) PatchElement(c context.Context, signedInUser identity.Requester, cmd model.PatchLibraryElementCommand, uid string) (model.LibraryElementDTO, error) {
panic("unimplemented")
}
func (l *LibraryElementService) GetElement(c context.Context, signedInUser identity.Requester, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) {
l.mx.RLock()
defer l.mx.RUnlock()
@@ -2,13 +2,13 @@ package libraryelements
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/grafana/pkg/api/dtos"
@@ -23,7 +23,6 @@ import (
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestUnstructuredToLegacyLibraryPanelDTO(t *testing.T) {
@@ -155,26 +154,22 @@ func TestUnstructuredToLegacyLibraryPanelDTO(t *testing.T) {
require.Equal(t, testUser.Login, result.Meta.UpdatedBy.Name)
require.Equal(t, dtos.GetGravatarUrl(cfg, testUser.Email), result.Meta.UpdatedBy.AvatarUrl)
var modelMap map[string]interface{}
err = json.Unmarshal(result.Model, &modelMap)
require.NoError(t, err)
require.Equal(t, "testdata", modelMap["datasource"].(map[string]interface{})["type"])
require.Equal(t, "test-datasource", modelMap["datasource"].(map[string]interface{})["uid"])
require.Equal(t, "Test description", modelMap["description"])
require.Equal(t, float64(123), modelMap["id"])
require.Equal(t, "text", modelMap["type"])
require.Equal(t, "Test Panel Title", modelMap["title"])
require.Equal(t, "Test content", modelMap["options"].(map[string]interface{})["content"])
require.Equal(t, true, modelMap["transparent"])
require.Equal(t, "Test Library Panel", modelMap["libraryPanel"].(map[string]interface{})["name"])
require.Equal(t, "test-panel-uid", modelMap["libraryPanel"].(map[string]interface{})["uid"])
links := modelMap["links"].([]interface{})
require.Len(t, links, 1)
require.Equal(t, "Test Link", links[0].(map[string]interface{})["title"])
targets := modelMap["targets"].([]interface{})
require.Len(t, targets, 1)
require.Equal(t, "A", targets[0].(map[string]interface{})["refId"])
// fmt.Printf("%s\n", result.Model)
require.JSONEq(t, `{
"datasource": { "type": "testdata", "uid": "test-datasource" },
"description": "Test description",
"fieldConfig": { "defaults": { "color": { "mode": "palette-classic" } } },
"gridPos": { "w": 12, "h": 8, "x": 0, "y": 0 },
"id": 123,
"libraryPanel": { "name": "Test Library Panel", "uid": "test-panel-uid" },
"links": [{ "title": "Test Link", "url": "https://example.com" }],
"options": { "content": "Test content" },
"pluginVersion": "1.0.0",
"targets": [{ "refId": "A", "expr": "test_query" }],
"title": "Test Panel Title",
"transparent": true,
"type": "text"
}`, string(result.Model))
dashboardsSvc.AssertExpectations(t)
}
@@ -40,6 +40,8 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout
// Service is a service for operating on library elements.
type Service interface {
CreateElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error)
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
@@ -63,11 +65,6 @@ type LibraryElementService struct {
var _ Service = (*LibraryElementService)(nil)
// CreateElement creates a Library Element.
func (l *LibraryElementService) CreateElement(c context.Context, signedInUser identity.Requester, cmd model.CreateLibraryElementCommand) (model.LibraryElementDTO, error) {
return l.createLibraryElement(c, signedInUser, cmd)
}
// GetElement gets an element from a UID.
func (l *LibraryElementService) GetElement(c context.Context, signedInUser identity.Requester, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) {
return l.getLibraryElementByUid(c, signedInUser, cmd)
@@ -103,7 +103,7 @@ func TestIntegration_GetLibraryElement(t *testing.T) {
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}
result, err := sc.service.createLibraryElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, model.CreateLibraryElementCommand{
result, 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",
@@ -150,7 +150,7 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) {
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.createLibraryElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, model.CreateLibraryElementCommand{
_, 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",