diff --git a/pkg/apis/dashboard/v0alpha1/register.go b/pkg/apis/dashboard/v0alpha1/register.go index 103f9e579db..c7c4b742432 100644 --- a/pkg/apis/dashboard/v0alpha1/register.go +++ b/pkg/apis/dashboard/v0alpha1/register.go @@ -38,7 +38,35 @@ var DashboardResourceInfo = common.NewResourceInfo(GROUP, VERSION, }, nil } } - return nil, fmt.Errorf("expected dashboard or summary") + return nil, fmt.Errorf("expected dashboard") + }, + }, +) + +var LibraryPanelResourceInfo = common.NewResourceInfo(GROUP, VERSION, + "librarypanels", "librarypanel", "LibraryPanel", + func() runtime.Object { return &LibraryPanel{} }, + func() runtime.Object { return &LibraryPanelList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Title", Type: "string", Description: "The dashboard name"}, + {Name: "Type", Type: "string", Description: "the panel type"}, + {Name: "Created At", Type: "date"}, + }, + Reader: func(obj any) ([]interface{}, error) { + dash, ok := obj.(*LibraryPanel) + if ok { + if dash != nil { + return []interface{}{ + dash.Name, + dash.Spec.Title, + dash.Spec.Type, + dash.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + } + } + return nil, fmt.Errorf("expected library panel") }, }, ) diff --git a/pkg/apis/dashboard/v0alpha1/types.go b/pkg/apis/dashboard/v0alpha1/types.go index b2fd3897317..81763a61239 100644 --- a/pkg/apis/dashboard/v0alpha1/types.go +++ b/pkg/apis/dashboard/v0alpha1/types.go @@ -3,6 +3,7 @@ package v0alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" ) @@ -66,6 +67,65 @@ type VersionsQueryOptions struct { Version int64 `json:"version,omitempty"` } +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type LibraryPanel struct { + metav1.TypeMeta `json:",inline"` + // Standard object's metadata + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Panel properties + Spec LibraryPanelSpec `json:"spec"` + + // Status will show errors + Status *LibraryPanelStatus `json:"status,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type LibraryPanelList struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + Items []LibraryPanel `json:"items,omitempty"` +} + +type LibraryPanelSpec struct { + // The panel type + Type string `json:"type"` + + // The panel type + PluginVersion string `json:"pluginVersion,omitempty"` + + // The panel title + Title string `json:"title,omitempty"` + + // Library panel description + Description string `json:"description,omitempty"` + + // The options schema depends on the panel type + Options common.Unstructured `json:"options"` + + // The fieldConfig schema depends on the panel type + FieldConfig common.Unstructured `json:"fieldConfig"` + + // The default datasource type + Datasource *data.DataSourceRef `json:"datasource,omitempty"` + + // The datasource queries + // +listType=set + Targets []data.DataQuery `json:"targets,omitempty"` +} + +type LibraryPanelStatus struct { + // Translation warnings (mostly things that were in SQL columns but not found in the saved body) + Warnings []string `json:"warnings,omitempty"` + + // The properties previously stored in SQL that are not included in this model + Missing common.Unstructured `json:"missing,omitempty"` +} + // This is like the legacy DTO where access and metadata are all returned in a single call // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type DashboardWithAccessInfo struct { diff --git a/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go index 46e4700ab07..38f222045e1 100644 --- a/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/dashboard/v0alpha1/zz_generated.deepcopy.go @@ -8,6 +8,7 @@ package v0alpha1 import ( + datav0alpha1 "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -199,6 +200,123 @@ func (in *DashboardWithAccessInfo) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibraryPanel) DeepCopyInto(out *LibraryPanel) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + if in.Status != nil { + in, out := &in.Status, &out.Status + *out = new(LibraryPanelStatus) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibraryPanel. +func (in *LibraryPanel) DeepCopy() *LibraryPanel { + if in == nil { + return nil + } + out := new(LibraryPanel) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LibraryPanel) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibraryPanelList) DeepCopyInto(out *LibraryPanelList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LibraryPanel, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibraryPanelList. +func (in *LibraryPanelList) DeepCopy() *LibraryPanelList { + if in == nil { + return nil + } + out := new(LibraryPanelList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LibraryPanelList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibraryPanelSpec) DeepCopyInto(out *LibraryPanelSpec) { + *out = *in + in.Options.DeepCopyInto(&out.Options) + in.FieldConfig.DeepCopyInto(&out.FieldConfig) + if in.Datasource != nil { + in, out := &in.Datasource, &out.Datasource + *out = new(datav0alpha1.DataSourceRef) + **out = **in + } + if in.Targets != nil { + in, out := &in.Targets, &out.Targets + *out = make([]datav0alpha1.DataQuery, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibraryPanelSpec. +func (in *LibraryPanelSpec) DeepCopy() *LibraryPanelSpec { + if in == nil { + return nil + } + out := new(LibraryPanelSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LibraryPanelStatus) DeepCopyInto(out *LibraryPanelStatus) { + *out = *in + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Missing.DeepCopyInto(&out.Missing) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LibraryPanelStatus. +func (in *LibraryPanelStatus) DeepCopy() *LibraryPanelStatus { + if in == nil { + return nil + } + out := new(LibraryPanelStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VersionsQueryOptions) DeepCopyInto(out *VersionsQueryOptions) { *out = *in diff --git a/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go b/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go index 5b43ab61377..2744c84dbea 100644 --- a/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/dashboard/v0alpha1/zz_generated.openapi.go @@ -22,6 +22,10 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardVersionInfo": schema_pkg_apis_dashboard_v0alpha1_DashboardVersionInfo(ref), "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardVersionList": schema_pkg_apis_dashboard_v0alpha1_DashboardVersionList(ref), "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.DashboardWithAccessInfo": schema_pkg_apis_dashboard_v0alpha1_DashboardWithAccessInfo(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanel": schema_pkg_apis_dashboard_v0alpha1_LibraryPanel(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanelList": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelList(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanelSpec": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref), + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanelStatus": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelStatus(ref), "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.VersionsQueryOptions": schema_pkg_apis_dashboard_v0alpha1_VersionsQueryOptions(ref), } } @@ -392,6 +396,217 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardWithAccessInfo(ref common.Refer } } +func schema_pkg_apis_dashboard_v0alpha1_LibraryPanel(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Description: "Standard object's metadata More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "Panel properties", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanelSpec"), + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Description: "Status will show errors", + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanelStatus"), + }, + }, + }, + Required: []string{"spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanelSpec", "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanelStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanel"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1.LibraryPanel", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "type": { + SchemaProps: spec.SchemaProps{ + Description: "The panel type", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "pluginVersion": { + SchemaProps: spec.SchemaProps{ + Description: "The panel type", + Type: []string{"string"}, + Format: "", + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Description: "The panel title", + Type: []string{"string"}, + Format: "", + }, + }, + "description": { + SchemaProps: spec.SchemaProps{ + Description: "Library panel description", + Type: []string{"string"}, + Format: "", + }, + }, + "options": { + SchemaProps: spec.SchemaProps{ + Description: "The options schema depends on the panel type", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + "fieldConfig": { + SchemaProps: spec.SchemaProps{ + Description: "The fieldConfig schema depends on the panel type", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + "datasource": { + SchemaProps: spec.SchemaProps{ + Description: "The default datasource type", + Ref: ref("github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef"), + }, + }, + "targets": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "The datasource queries", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery"), + }, + }, + }, + }, + }, + }, + Required: []string{"type", "options", "fieldConfig"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataQuery", "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef", "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, + } +} + +func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "warnings": { + SchemaProps: spec.SchemaProps{ + Description: "Translation warnings (mostly things that were in SQL columns but not found in the saved body)", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "missing": { + SchemaProps: spec.SchemaProps{ + Description: "The properties previously stored in SQL that are not included in this model", + Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"}, + } +} + func schema_pkg_apis_dashboard_v0alpha1_VersionsQueryOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 00000000000..8131ccde7c9 --- /dev/null +++ b/pkg/apis/dashboard/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1,LibraryPanelStatus,Warnings diff --git a/pkg/registry/apis/dashboard/legacy/queries.go b/pkg/registry/apis/dashboard/legacy/queries.go index 6032e791b90..9c4688202ba 100644 --- a/pkg/registry/apis/dashboard/legacy/queries.go +++ b/pkg/registry/apis/dashboard/legacy/queries.go @@ -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"), + } +} diff --git a/pkg/registry/apis/dashboard/legacy/queries_test.go b/pkg/registry/apis/dashboard/legacy/queries_test.go index 5560cc4dd94..325eadfdd9f 100644 --- a/pkg/registry/apis/dashboard/legacy/queries_test.go +++ b/pkg/registry/apis/dashboard/legacy/queries_test.go @@ -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", + }), + }, + }, }, }) } diff --git a/pkg/registry/apis/dashboard/legacy/query_panels.sql b/pkg/registry/apis/dashboard/legacy/query_panels.sql new file mode 100644 index 00000000000..d106b17b6bf --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/query_panels.sql @@ -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 }} \ No newline at end of file diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index b47a952d0c4..0c3a2decddd 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -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 +} diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-get_uid.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-get_uid.sql new file mode 100755 index 00000000000..20c1ca1da99 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-get_uid.sql @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list.sql new file mode 100755 index 00000000000..910f37d4c3f --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list.sql @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list_page_two.sql b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list_page_two.sql new file mode 100755 index 00000000000..59578f156a9 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/testdata/mysql--query_panels-list_page_two.sql @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-get_uid.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-get_uid.sql new file mode 100755 index 00000000000..20c1ca1da99 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-get_uid.sql @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list.sql new file mode 100755 index 00000000000..910f37d4c3f --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list.sql @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list_page_two.sql b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list_page_two.sql new file mode 100755 index 00000000000..59578f156a9 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/testdata/postgres--query_panels-list_page_two.sql @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-get_uid.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-get_uid.sql new file mode 100755 index 00000000000..20c1ca1da99 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-get_uid.sql @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list.sql new file mode 100755 index 00000000000..910f37d4c3f --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list.sql @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list_page_two.sql b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list_page_two.sql new file mode 100755 index 00000000000..59578f156a9 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/testdata/sqlite--query_panels-list_page_two.sql @@ -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 diff --git a/pkg/registry/apis/dashboard/legacy/types.go b/pkg/registry/apis/dashboard/legacy/types.go index ba3a0e61251..ad46cc87978 100644 --- a/pkg/registry/apis/dashboard/legacy/types.go +++ b/pkg/registry/apis/dashboard/legacy/types.go @@ -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) } diff --git a/pkg/registry/apis/dashboard/libary_panel.go b/pkg/registry/apis/dashboard/libary_panel.go new file mode 100644 index 00000000000..c00e21a2562 --- /dev/null +++ b/pkg/registry/apis/dashboard/libary_panel.go @@ -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) +} diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 2c3c5f5621c..5f6178d410e 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -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 } diff --git a/pkg/registry/apis/dashboard/storage.go b/pkg/registry/apis/dashboard/storage.go index 8995513eb6f..bc50691d5f0 100644 --- a/pkg/registry/apis/dashboard/storage.go +++ b/pkg/registry/apis/dashboard/storage.go @@ -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, diff --git a/pkg/services/apiserver/builder/openapi.go b/pkg/services/apiserver/builder/openapi.go index 0790ba733aa..98bd88fca7c 100644 --- a/pkg/services/apiserver/builder/openapi.go +++ b/pkg/services/apiserver/builder/openapi.go @@ -17,6 +17,17 @@ func GetOpenAPIDefinitions(builders []APIGroupBuilder) common.GetOpenAPIDefiniti return func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { defs := v0alpha1.GetOpenAPIDefinitions(ref) // common grafana apis maps.Copy(defs, data.GetOpenAPIDefinitions(ref)) + // TODO: remove when https://github.com/grafana/grafana-plugin-sdk-go/pull/1062 is merged + maps.Copy(defs, map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1.DataSourceRef": { + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{Allows: true}, + }, + }, + }, + }) for _, b := range builders { g := b.GetOpenAPIDefinitions() if g != nil { diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index 1fb328153b6..a5aa7808f36 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -159,6 +159,20 @@ func TestIntegrationDashboardsApp(t *testing.T) { "update", "watch" ] + }, + { + "resource": "librarypanels", + "responseKind": { + "group": "", + "kind": "LibraryPanel", + "version": "" + }, + "scope": "Namespaced", + "singularResource": "librarypanel", + "verbs": [ + "get", + "list" + ] } ], "version": "v0alpha1"