LibraryPanels: Expose library panels in dashboard apiserver (#92213)

This commit is contained in:
Ryan McKinley
2024-08-27 12:12:22 +03:00
committed by GitHub
parent 7ad3d9bf76
commit 419edef4dc
24 changed files with 867 additions and 5 deletions
@@ -27,6 +27,7 @@ func mustTemplate(filename string) *template.Template {
// Templates.
var (
sqlQueryDashboards = mustTemplate("query_dashboards.sql")
sqlQueryPanels = mustTemplate("query_panels.sql")
)
type sqlQuery struct {
@@ -54,3 +55,25 @@ func newQueryReq(sql *legacysql.LegacyDatabaseHelper, query *DashboardQuery) sql
UserTable: sql.Table("user"),
}
}
type sqlLibraryQuery struct {
sqltemplate.SQLTemplate
Query *LibraryPanelQuery
LibraryElementTable string
UserTable string
}
func (r sqlLibraryQuery) Validate() error {
return nil // TODO
}
func newLibraryQueryReq(sql *legacysql.LegacyDatabaseHelper, query *LibraryPanelQuery) sqlLibraryQuery {
return sqlLibraryQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
Query: query,
LibraryElementTable: sql.Table("library_element"),
UserTable: sql.Table("user"),
}
}
@@ -23,6 +23,12 @@ func TestQueries(t *testing.T) {
return &v
}
getLibraryQuery := func(q *LibraryPanelQuery) sqltemplate.SQLTemplate {
v := newLibraryQueryReq(nodb, q)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{
RootDir: "testdata",
Templates: map[*template.Template][]mocks.TemplateTestCase{
@@ -64,6 +70,29 @@ func TestQueries(t *testing.T) {
}),
},
},
sqlQueryPanels: {
{
Name: "list",
Data: getLibraryQuery(&LibraryPanelQuery{
OrgID: 1,
Limit: 5,
}),
},
{
Name: "list_page_two",
Data: getLibraryQuery(&LibraryPanelQuery{
OrgID: 1,
LastID: 4,
}),
},
{
Name: "get_uid",
Data: getLibraryQuery(&LibraryPanelQuery{
OrgID: 1,
UID: "xyz",
}),
},
},
},
})
}
@@ -0,0 +1,18 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM {{ .Ident .LibraryElementTable }} as p
LEFT OUTER JOIN {{ .Ident .UserTable }} AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN {{ .Ident .UserTable }} AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = {{ .Arg .Query.OrgID }}
{{ if .Query.LastID }}
AND p.id > {{ .Arg .Query.LastID }}
{{ end }}
{{ if .Query.UID }}
AND p.uid = {{ .Arg .Query.UID }}
{{ end }}
ORDER BY p.id DESC
{{ if .Query.Limit }}
LIMIT {{ .Arg .Query.Limit }}
{{ end }}
@@ -6,10 +6,12 @@ import (
"encoding/json"
"fmt"
"path/filepath"
"strconv"
"sync"
"time"
"github.com/grafana/authlib/claims"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
@@ -50,7 +52,8 @@ type dashboardSqlAccess struct {
provisioning provisioning.ProvisioningService
// Use for writing (not reading)
dashStore dashboards.Store
dashStore dashboards.Store
softDelete bool
// Typically one... the server wrapper
subscribers []chan *resource.WrittenEvent
@@ -61,12 +64,14 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
namespacer request.NamespaceMapper,
dashStore dashboards.Store,
provisioning provisioning.ProvisioningService,
softDelete bool,
) DashboardAccess {
return &dashboardSqlAccess{
sql: sql,
namespacer: namespacer,
dashStore: dashStore,
provisioning: provisioning,
softDelete: softDelete,
}
}
@@ -314,6 +319,16 @@ func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, u
return nil, false, err
}
if a.softDelete {
err = a.dashStore.SoftDeleteDashboard(ctx, orgId, uid)
if err == nil && dash != nil {
now := metav1.NewTime(time.Now())
dash.DeletionTimestamp = &now
return dash, true, err
}
return dash, false, err
}
id := dash.Spec.GetNestedInt64("id")
if id == 0 {
return nil, false, fmt.Errorf("could not find id in saved body")
@@ -383,3 +398,128 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das
dash, _, err = a.GetDashboard(ctx, orgId, out.UID, 0)
return dash, created, err
}
func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboardsV0.LibraryPanelList, error) {
limit := int(query.Limit)
query.Limit += 1 // for continue
if query.OrgID == 0 {
return nil, fmt.Errorf("expected non zero orgID")
}
sql, err := a.sql(ctx)
if err != nil {
return nil, err
}
req := newLibraryQueryReq(sql, &query)
rawQuery, err := sqltemplate.Execute(sqlQueryPanels, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", sqlQueryPanels.Name(), err)
}
q := rawQuery
res := &dashboardsV0.LibraryPanelList{}
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
}
}()
if err != nil {
return nil, err
}
type panel struct {
ID int64
UID string
FolderUID string
Created time.Time
CreatedBy string
Updated time.Time
UpdatedBy string
Name string
Type string
Description string
Model []byte
}
var lastID int64
for rows.Next() {
p := panel{}
err = rows.Scan(&p.ID, &p.UID, &p.FolderUID,
&p.Created, &p.CreatedBy,
&p.Updated, &p.UpdatedBy,
&p.Name, &p.Type, &p.Description, &p.Model,
)
if err != nil {
return res, err
}
lastID = p.ID
item := dashboardsV0.LibraryPanel{
ObjectMeta: metav1.ObjectMeta{
Name: p.UID,
CreationTimestamp: metav1.NewTime(p.Created),
ResourceVersion: strconv.FormatInt(p.Updated.UnixMilli(), 10),
},
Spec: dashboardsV0.LibraryPanelSpec{},
}
status := &dashboardsV0.LibraryPanelStatus{
Missing: v0alpha1.Unstructured{},
}
err = json.Unmarshal(p.Model, &item.Spec)
if err != nil {
return nil, err
}
err = json.Unmarshal(p.Model, &status.Missing.Object)
if err != nil {
return nil, err
}
if item.Spec.Title != p.Name {
status.Warnings = append(item.Status.Warnings, fmt.Sprintf("title mismatch (expected: %s)", p.Name))
}
if item.Spec.Description != p.Description {
status.Warnings = append(item.Status.Warnings, fmt.Sprintf("description mismatch (expected: %s)", p.Description))
}
if item.Spec.Type != p.Type {
status.Warnings = append(item.Status.Warnings, fmt.Sprintf("type mismatch (expected: %s)", p.Type))
}
item.Status = status
// Remove the properties we are already showing
for _, k := range []string{"type", "pluginVersion", "title", "description", "options", "fieldConfig", "datasource", "targets", "libraryPanel"} {
delete(status.Missing.Object, k)
}
meta, err := utils.MetaAccessor(&item)
if err != nil {
return nil, err
}
meta.SetFolder(p.FolderUID)
meta.SetCreatedBy(p.CreatedBy)
meta.SetUpdatedBy(p.UpdatedBy)
meta.SetUpdatedTimestamp(&p.Updated)
meta.SetOriginInfo(&utils.ResourceOriginInfo{
Name: "SQL",
Path: strconv.FormatInt(p.ID, 10),
})
res.Items = append(res.Items, item)
if len(res.Items) > limit {
res.Continue = strconv.FormatInt(lastID, 10)
break
}
}
if query.UID == "" {
rv, err := sql.GetResourceVersion(ctx, "library_element", "updated")
if err == nil {
res.ResourceVersion = strconv.FormatInt(rv, 10)
}
}
return res, err
}
@@ -0,0 +1,10 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM "grafana.library_element" as p
LEFT OUTER JOIN "grafana.user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana.user" AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = 1
AND p.uid = 'xyz'
ORDER BY p.id DESC
@@ -0,0 +1,10 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM "grafana.library_element" as p
LEFT OUTER JOIN "grafana.user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana.user" AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = 1
ORDER BY p.id DESC
LIMIT 5
@@ -0,0 +1,10 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM "grafana.library_element" as p
LEFT OUTER JOIN "grafana.user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana.user" AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = 1
AND p.id > 4
ORDER BY p.id DESC
@@ -0,0 +1,10 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM "grafana.library_element" as p
LEFT OUTER JOIN "grafana.user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana.user" AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = 1
AND p.uid = 'xyz'
ORDER BY p.id DESC
@@ -0,0 +1,10 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM "grafana.library_element" as p
LEFT OUTER JOIN "grafana.user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana.user" AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = 1
ORDER BY p.id DESC
LIMIT 5
@@ -0,0 +1,10 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM "grafana.library_element" as p
LEFT OUTER JOIN "grafana.user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana.user" AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = 1
AND p.id > 4
ORDER BY p.id DESC
@@ -0,0 +1,10 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM "grafana.library_element" as p
LEFT OUTER JOIN "grafana.user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana.user" AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = 1
AND p.uid = 'xyz'
ORDER BY p.id DESC
@@ -0,0 +1,10 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM "grafana.library_element" as p
LEFT OUTER JOIN "grafana.user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana.user" AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = 1
ORDER BY p.id DESC
LIMIT 5
@@ -0,0 +1,10 @@
SELECT p.id, p.uid, p.folder_uid,
p.created, created_user.uid as created_by,
p.updated, updated_user.uid as updated_by,
p.name, p.type, p.description, p.model
FROM "grafana.library_element" as p
LEFT OUTER JOIN "grafana.user" AS created_user ON p.created_by = created_user.id
LEFT OUTER JOIN "grafana.user" AS updated_user ON p.updated_by = updated_user.id
WHERE p.org_id = 1
AND p.id > 4
ORDER BY p.id DESC
@@ -33,6 +33,16 @@ func (r *DashboardQuery) UseHistoryTable() bool {
return r.GetHistory || r.Version > 0
}
type LibraryPanelQuery struct {
OrgID int64
UID string // to select a single dashboard
Limit int64
// Included in the continue token
// This is the ID from the last dashboard sent in the previous page
LastID int64
}
type DashboardAccess interface {
resource.StorageBackend
resource.ResourceIndexServer
@@ -40,4 +50,7 @@ type DashboardAccess interface {
GetDashboard(ctx context.Context, orgId int64, uid string, version int64) (*dashboardsV0.Dashboard, int64, error)
SaveDashboard(ctx context.Context, orgId int64, dash *dashboardsV0.Dashboard) (*dashboardsV0.Dashboard, bool, error)
DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, bool, error)
// Get a typed list
GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboardsV0.LibraryPanelList, error)
}
@@ -0,0 +1,94 @@
package dashboard
import (
"context"
"fmt"
"strconv"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
)
var (
_ rest.Scoper = (*libraryPanelStore)(nil)
_ rest.SingularNameProvider = (*libraryPanelStore)(nil)
_ rest.Getter = (*libraryPanelStore)(nil)
_ rest.Lister = (*libraryPanelStore)(nil)
_ rest.Storage = (*libraryPanelStore)(nil)
)
var lpr = dashboard.LibraryPanelResourceInfo
type libraryPanelStore struct {
access legacy.DashboardAccess
}
func (s *libraryPanelStore) New() runtime.Object {
return lpr.NewFunc()
}
func (s *libraryPanelStore) Destroy() {}
func (s *libraryPanelStore) NamespaceScoped() bool {
return true // namespace == org
}
func (s *libraryPanelStore) GetSingularName() string {
return lpr.GetSingularName()
}
func (s *libraryPanelStore) NewList() runtime.Object {
return lpr.NewListFunc()
}
func (s *libraryPanelStore) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return lpr.TableConverter().ConvertToTable(ctx, object, tableOptions)
}
func (s *libraryPanelStore) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
query := legacy.LibraryPanelQuery{
OrgID: ns.OrgID,
Limit: options.Limit,
}
if options.Continue != "" {
query.LastID, err = strconv.ParseInt(options.Continue, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid continue token")
}
}
if query.Limit < 1 {
query.Limit = 25
}
return s.access.GetLibraryPanels(ctx, query)
}
func (s *libraryPanelStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
query := legacy.LibraryPanelQuery{
OrgID: ns.OrgID,
UID: name,
Limit: 1,
}
found, err := s.access.GetLibraryPanels(ctx, query)
if err != nil {
return nil, err
}
if len(found.Items) == 1 {
return &found.Items[0], nil
}
return nil, lpr.NewNotFound(name)
}
+9 -1
View File
@@ -56,6 +56,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
return nil // skip registration unless opting into experimental apis
}
softDelete := features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore)
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
builder := &DashboardsAPIBuilder{
@@ -66,7 +67,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
legacy: &dashboardStorage{
resource: dashboard.DashboardResourceInfo,
access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning),
access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, softDelete),
tableConverter: dashboard.DashboardResourceInfo.TableConverter(),
},
}
@@ -90,6 +91,8 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
&dashboard.DashboardWithAccessInfo{},
&dashboard.DashboardVersionList{},
&dashboard.VersionsQueryOptions{},
&dashboard.LibraryPanel{},
&dashboard.LibraryPanelList{},
&metav1.PartialObjectMetadata{},
&metav1.PartialObjectMetadataList{},
)
@@ -156,6 +159,11 @@ func (b *DashboardsAPIBuilder) GetAPIGroupInfo(
}
}
// Expose read only library panels
storage[dashboard.LibraryPanelResourceInfo.StoragePath()] = &libraryPanelStore{
access: b.legacy.access,
}
apiGroupInfo.VersionedResourcesStorageMap[dashboard.VERSION] = storage
return &apiGroupInfo, nil
}
+2 -2
View File
@@ -4,7 +4,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
"github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
)
@@ -17,7 +17,7 @@ type storage struct {
func newStorage(scheme *runtime.Scheme) (*storage, error) {
strategy := grafanaregistry.NewStrategy(scheme)
resourceInfo := v0alpha1.DashboardResourceInfo
resourceInfo := dashboard.DashboardResourceInfo
store := &genericregistry.Store{
NewFunc: resourceInfo.NewFunc,
NewListFunc: resourceInfo.NewListFunc,