From 42d75ac737d7ac001a6d53376e25512408a01db5 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Thu, 16 May 2024 14:36:26 -0300 Subject: [PATCH] Dashboards: Add feature restore dashboards backend (#83131) Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> --- docs/sources/developers/http_api/dashboard.md | 95 +++++++++++ .../src/types/featureToggles.gen.ts | 1 + pkg/api/api.go | 14 +- pkg/api/dashboard.go | 158 +++++++++++++++++- pkg/api/search.go | 6 + pkg/services/cleanup/cleanup.go | 56 ++++--- pkg/services/dashboards/dashboard.go | 10 ++ .../dashboards/dashboard_service_mock.go | 96 ++++++++++- pkg/services/dashboards/database/database.go | 104 ++++++++++-- .../dashboards/database/database_test.go | 128 +++++++++++++- pkg/services/dashboards/errors.go | 5 + pkg/services/dashboards/models.go | 16 +- .../dashboards/service/dashboard_service.go | 83 +++++++++ .../service/dashboard_service_test.go | 9 + pkg/services/dashboards/store_mock.go | 122 +++++++++++++- pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 16 +- pkg/services/folder/folderimpl/folder.go | 10 +- pkg/services/folder/folderimpl/folder_test.go | 61 +++++++ pkg/services/search/model/model.go | 32 ++-- pkg/services/search/service.go | 2 + .../sqlstore/migrations/dashboard_mig.go | 9 + pkg/services/sqlstore/searchstore/builder.go | 1 + pkg/services/sqlstore/searchstore/filters.go | 12 ++ pkg/util/strings.go | 15 ++ public/api-enterprise-spec.json | 25 ++- public/api-merged.json | 109 +++++++++++- public/openapi3.json | 118 ++++++++++++- 30 files changed, 1230 insertions(+), 96 deletions(-) diff --git a/docs/sources/developers/http_api/dashboard.md b/docs/sources/developers/http_api/dashboard.md index c818e470929..e51af8a6575 100644 --- a/docs/sources/developers/http_api/dashboard.md +++ b/docs/sources/developers/http_api/dashboard.md @@ -253,6 +253,101 @@ Status Codes: - **403** – Access denied - **404** – Not found +## Hard delete dashboard by uid + +{{% admonition type="note" %}} +This feature is currently in private preview and behind the `dashboardRestore` feature toggle. +{{% /admonition %}} + +`DELETE /api/dashboards/uid/:uid/trash` + +Will delete permanently the dashboard given the specified unique identifier (uid). + +**Required permissions** + +See note in the [introduction]({{< ref "#dashboard-api" >}}) for an explanation. + +| Action | Scope | +| ------------------- | ----------------------------- | +| `dashboards:delete` | `dashboards:*`
`folders:*` | + +**Example Request**: + +```http +DELETE /api/dashboards/uid/cIBgcSjkk/trash HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "title": "Production Overview", + "message": "Dashboard Production Overview deleted", + "uid": "cIBgcSjkk" +} +``` + +Status Codes: + +- **200** – Deleted +- **401** – Unauthorized +- **403** – Access denied +- **404** – Not found + +## Restore deleted dashboard by uid + +{{% admonition type="note" %}} +This feature is currently in private preview and behind the `dashboardRestore` feature toggle. +{{% /admonition %}} + +`PATCH /api/dashboards/uid/:uid/trash` + +Will restore a deleted dashboard given the specified unique identifier (uid). + +**Required permissions** + +See note in the [introduction]({{< ref "#dashboard-api" >}}) for an explanation. + +| Action | Scope | +| ------------------- | ----------------------------- | +| `dashboards:create` | `dashboards:*`
`folders:*` | + +**Example Request**: + +```http +PATCH /api/dashboards/uid/cIBgcSjkk/trash HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "title": "Production Overview", + "message": "Dashboard Production Overview restored", + "uid": "cIBgcSjkk" +} +``` + +Status Codes: + +- **200** – Deleted +- **401** – Unauthorized +- **403** – Access denied +- **404** – Not found +- + ## Gets the home dashboard `GET /api/dashboards/home` diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 6bc6776b125..234247bbdb3 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -188,4 +188,5 @@ export interface FeatureToggles { logsExploreTableDefaultVisualization?: boolean; newDashboardSharingComponent?: boolean; notificationBanner?: boolean; + dashboardRestore?: boolean; } diff --git a/pkg/api/api.go b/pkg/api/api.go index 0bd1dc6c9a3..76ff1a24c70 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -456,11 +456,23 @@ func (hs *HTTPServer) registerRoutes() { // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) { dashboardRoute.Get("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsRead)), routing.Wrap(hs.GetDashboard)) - dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete)), routing.Wrap(hs.DeleteDashboardByUID)) + + if hs.Features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { + dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete)), routing.Wrap(hs.SoftDeleteDashboard)) + } else { + dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete)), routing.Wrap(hs.DeleteDashboardByUID)) + } + dashboardRoute.Group("/uid/:uid", func(dashUidRoute routing.RouteRegister) { dashUidRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersions)) dashUidRoute.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.RestoreDashboardVersion)) dashUidRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersion)) + + if hs.Features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { + dashUidRoute.Patch("/trash", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.RestoreDeletedDashboard)) + dashUidRoute.Delete("/trash", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete)), routing.Wrap(hs.HardDeleteDashboardByUID)) + } + dashUidRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) { dashboardPermissionRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionDashboardsPermissionsRead)), routing.Wrap(hs.GetDashboardPermissionList)) dashboardPermissionRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionDashboardsPermissionsWrite)), routing.Wrap(hs.UpdateDashboardPermissions)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index c273e798930..380dee3a434 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -61,6 +61,10 @@ func (hs *HTTPServer) isDashboardStarredByUser(c *contextmodel.ReqContext, dashI func dashboardGuardianResponse(err error) response.Response { if err != nil { + var dashboardErr dashboards.DashboardErr + if ok := errors.As(err, &dashboardErr); ok { + return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), err) + } return response.Error(http.StatusInternalServerError, "Error while checking dashboard permissions", err) } return response.Error(http.StatusForbidden, "Access denied to this dashboard", nil) @@ -272,6 +276,101 @@ func (hs *HTTPServer) getDashboardHelper(ctx context.Context, orgID int64, id in return queryResult, nil } +// swagger:route PATCH /dashboards/uid/{uid}/trash dashboards restoreDeletedDashboardByUID +// +// Restore a dashboard to a given dashboard version using UID. +// +// Responses: +// 200: postDashboardResponse +// 400: badRequestError +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (hs *HTTPServer) RestoreDeletedDashboard(c *contextmodel.ReqContext) response.Response { + uid := web.Params(c.Req)[":uid"] + cmd := dashboards.RestoreDeletedDashboardCommand{} + + if err := web.Bind(c.Req, &cmd); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + dash, err := hs.DashboardService.GetSoftDeletedDashboard(c.Req.Context(), c.SignedInUser.GetOrgID(), uid) + if err != nil { + return response.Error(http.StatusNotFound, "Dashboard not found", err) + } + + guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser) + if err != nil { + return response.Err(err) + } + + if canRestore, err := guardian.CanSave(); err != nil || !canRestore { + return dashboardGuardianResponse(err) + } + + err = hs.DashboardService.RestoreDashboard(c.Req.Context(), dash, c.SignedInUser, cmd.FolderUID) + if err != nil { + var dashboardErr dashboards.DashboardErr + if ok := errors.As(err, &dashboardErr); ok { + return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), err) + } + return response.Error(http.StatusInternalServerError, "Dashboard cannot be restored", err) + } + + return response.JSON(http.StatusOK, util.DynMap{ + "title": dash.Title, + "message": fmt.Sprintf("Dashboard %s restored", dash.Title), + "uid": dash.UID, + }) +} + +// SoftDeleteDashboard swagger:route DELETE /dashboards/uid/{uid} dashboards deleteDashboardByUID +// +// Delete dashboard by uid. +// +// Will delete the dashboard given the specified unique identifier (uid). +// +// Responses: +// 200: deleteDashboardResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (hs *HTTPServer) SoftDeleteDashboard(c *contextmodel.ReqContext) response.Response { + uid := web.Params(c.Req)[":uid"] + dash, rsp := hs.getDashboardHelper(c.Req.Context(), c.SignedInUser.GetOrgID(), 0, uid) + if rsp != nil { + return rsp + } + + guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser) + if err != nil { + return response.Err(err) + } + + if canDelete, err := guardian.CanDelete(); err != nil || !canDelete { + return dashboardGuardianResponse(err) + } + + err = hs.DashboardService.SoftDeleteDashboard(c.Req.Context(), c.SignedInUser.GetOrgID(), uid) + if err != nil { + var dashboardErr dashboards.DashboardErr + if ok := errors.As(err, &dashboardErr); ok { + if errors.Is(err, dashboards.ErrDashboardCannotDeleteProvisionedDashboard) { + return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), err) + } + } + return response.Error(http.StatusInternalServerError, "Failed to delete dashboard", err) + } + + return response.JSON(http.StatusOK, util.DynMap{ + "title": dash.Title, + "message": fmt.Sprintf("Dashboard %s moved to trash", dash.Title), + "uid": dash.UID, + }) +} + // DeleteDashboardByUID swagger:route DELETE /dashboards/uid/{uid} dashboards deleteDashboardByUID // // Delete dashboard by uid. @@ -288,11 +387,40 @@ func (hs *HTTPServer) DeleteDashboardByUID(c *contextmodel.ReqContext) response. return hs.deleteDashboard(c) } +// HardDeleteDashboardByUID swagger:route DELETE /dashboards/uid/{uid}/trash dashboards hardDeleteDashboardByUID +// +// Hard delete dashboard by uid. +// +// Will delete the dashboard given the specified unique identifier (uid). +// +// Responses: +// 200: deleteDashboardResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (hs *HTTPServer) HardDeleteDashboardByUID(c *contextmodel.ReqContext) response.Response { + return hs.deleteDashboard(c) +} + func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Response { - dash, rsp := hs.getDashboardHelper(c.Req.Context(), c.SignedInUser.GetOrgID(), 0, web.Params(c.Req)[":uid"]) - if rsp != nil { - return rsp + uid := web.Params(c.Req)[":uid"] + + var dash *dashboards.Dashboard + if hs.Features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { + var err error + dash, err = hs.DashboardService.GetSoftDeletedDashboard(c.Req.Context(), c.SignedInUser.GetOrgID(), uid) + if err != nil { + return response.Error(http.StatusNotFound, "Dashboard not found", err) + } + } else { + var rsp response.Response + dash, rsp = hs.getDashboardHelper(c.Req.Context(), c.SignedInUser.GetOrgID(), 0, web.Params(c.Req)[":uid"]) + if rsp != nil { + return rsp + } } + guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser) if err != nil { return response.Err(err) @@ -346,7 +474,7 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo return response.JSON(http.StatusOK, util.DynMap{ "title": dash.Title, "message": fmt.Sprintf("Dashboard %s deleted", dash.Title), - "id": dash.ID, + "uid": dash.UID, }) } @@ -1098,6 +1226,13 @@ type DeleteDashboardByUIDParams struct { UID string `json:"uid"` } +// swagger:parameters hardDeleteDashboardByUID +type HardDeleteDashboardByUIDParams struct { + // in:path + // required:true + UID string `json:"uid"` +} + // swagger:parameters postDashboard type PostDashboardParams struct { // in:body @@ -1133,10 +1268,10 @@ type DeleteDashboardResponse struct { // The response message // in: body Body struct { - // ID Identifier of the deleted dashboard. + // UID Identifier of the deleted dashboard. // required: true // example: 65 - ID int64 `json:"id"` + UID string `json:"uid"` // Title Title of the deleted dashboard. // required: true @@ -1231,3 +1366,14 @@ type DashboardVersionResponse struct { // in: body Body *dashver.DashboardVersionMeta `json:"body"` } + +// swagger:parameters restoreDeletedDashboardByUID +type RestoreDeletedDashboardByUID struct { + // in:path + // required:true + UID string `json:"uid"` + + // in:body + // required:true + Body dashboards.RestoreDeletedDashboardCommand +} diff --git a/pkg/api/search.go b/pkg/api/search.go index 19c606f6c28..e6fa99db380 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -28,6 +28,7 @@ func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response { page := c.QueryInt64("page") dashboardType := c.Query("type") sort := c.Query("sort") + deleted := c.Query("deleted") permission := dashboardaccess.PERMISSION_VIEW if limit > 5000 { @@ -77,6 +78,7 @@ func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response { Limit: limit, Page: page, IsStarred: starred == "true", + IsDeleted: deleted == "true", OrgId: c.SignedInUser.GetOrgID(), DashboardIds: dbIDs, DashboardUIDs: dbUIDs, @@ -190,6 +192,10 @@ type SearchParams struct { // default: alpha-asc // Enum: alpha-asc,alpha-desc Sort string `json:"sort"` + // Flag indicating if only soft deleted Dashboards should be returned + // in:query + // required: false + Deleted bool `json:"deleted"` } // swagger:response searchResponse diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index 46037db3b4e..7604a402017 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/ngalert/image" @@ -26,27 +27,6 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func ProvideService(cfg *setting.Cfg, serverLockService *serverlock.ServerLockService, - shortURLService shorturls.Service, sqlstore db.DB, queryHistoryService queryhistory.Service, - dashboardVersionService dashver.Service, dashSnapSvc dashboardsnapshots.Service, deleteExpiredImageService *image.DeleteExpiredService, - tempUserService tempuser.Service, tracer tracing.Tracer, annotationCleaner annotations.Cleaner) *CleanUpService { - s := &CleanUpService{ - Cfg: cfg, - ServerLockService: serverLockService, - ShortURLService: shortURLService, - QueryHistoryService: queryHistoryService, - store: sqlstore, - log: log.New("cleanup"), - dashboardVersionService: dashboardVersionService, - dashboardSnapshotService: dashSnapSvc, - deleteExpiredImageService: deleteExpiredImageService, - tempUserService: tempUserService, - tracer: tracer, - annotationCleaner: annotationCleaner, - } - return s -} - type CleanUpService struct { log log.Logger tracer tracing.Tracer @@ -60,6 +40,29 @@ type CleanUpService struct { deleteExpiredImageService *image.DeleteExpiredService tempUserService tempuser.Service annotationCleaner annotations.Cleaner + dashboardService dashboards.DashboardService +} + +func ProvideService(cfg *setting.Cfg, serverLockService *serverlock.ServerLockService, + shortURLService shorturls.Service, sqlstore db.DB, queryHistoryService queryhistory.Service, + dashboardVersionService dashver.Service, dashSnapSvc dashboardsnapshots.Service, deleteExpiredImageService *image.DeleteExpiredService, + tempUserService tempuser.Service, tracer tracing.Tracer, annotationCleaner annotations.Cleaner, dashboardService dashboards.DashboardService) *CleanUpService { + s := &CleanUpService{ + Cfg: cfg, + ServerLockService: serverLockService, + ShortURLService: shortURLService, + QueryHistoryService: queryHistoryService, + store: sqlstore, + log: log.New("cleanup"), + dashboardVersionService: dashboardVersionService, + dashboardSnapshotService: dashSnapSvc, + deleteExpiredImageService: deleteExpiredImageService, + tempUserService: tempUserService, + tracer: tracer, + annotationCleaner: annotationCleaner, + dashboardService: dashboardService, + } + return s } type cleanUpJob struct { @@ -103,6 +106,7 @@ func (srv *CleanUpService) clean(ctx context.Context) { {"delete stale short URLs", srv.deleteStaleShortURLs}, {"delete stale query history", srv.deleteStaleQueryHistory}, {"expire old email verifications", srv.expireOldVerifications}, + {"cleanup trash dashboards", srv.cleanUpTrashDashboards}, } logger := srv.log.FromContext(ctx) @@ -296,3 +300,13 @@ func (srv *CleanUpService) deleteStaleQueryHistory(ctx context.Context) { logger.Debug("Enforced row limit for query_history_star", "rows affected", rowsCount) } } + +func (srv *CleanUpService) cleanUpTrashDashboards(ctx context.Context) { + logger := srv.log.FromContext(ctx) + affected, err := srv.dashboardService.CleanUpDeletedDashboards(ctx) + if err != nil { + logger.Error("Problem cleaning up deleted dashboards", "error", err) + } else { + logger.Debug("Cleaned up deleted dashboards", "dashboards affected", affected) + } +} diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index ac1b89954d9..99517ea951f 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -2,6 +2,7 @@ package dashboards import ( "context" + "time" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/folder" @@ -29,6 +30,10 @@ type DashboardService interface { CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) (int64, error) GetDashboardsSharedWithUser(ctx context.Context, user identity.Requester) ([]*Dashboard, error) GetAllDashboards(ctx context.Context) ([]*Dashboard, error) + SoftDeleteDashboard(ctx context.Context, orgID int64, dashboardUid string) error + RestoreDashboard(ctx context.Context, dashboard *Dashboard, user identity.Requester, optionalFolderUID string) error + CleanUpDeletedDashboards(ctx context.Context) (int64, error) + GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*Dashboard, error) } // PluginService is a service for operating on plugin dashboards. @@ -79,4 +84,9 @@ type Store interface { DeleteDashboardsInFolders(ctx context.Context, request *DeleteDashboardsInFolderRequest) error GetAllDashboards(ctx context.Context) ([]*Dashboard, error) + GetSoftDeletedExpiredDashboards(ctx context.Context, duration time.Duration) ([]*Dashboard, error) + SoftDeleteDashboard(ctx context.Context, orgID int64, dashboardUid string) error + SoftDeleteDashboardsInFolders(ctx context.Context, orgID int64, folderUids []string) error + RestoreDashboard(ctx context.Context, orgID int64, dashboardUid string, folder *folder.Folder) error + GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*Dashboard, error) } diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index ac7d52fefc5..0c79642d270 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.40.1. DO NOT EDIT. +// Code generated by mockery v2.42.2. DO NOT EDIT. package dashboards @@ -46,6 +46,34 @@ func (_m *FakeDashboardService) BuildSaveDashboardCommand(ctx context.Context, d return r0, r1 } +// CleanUpDeletedDashboards provides a mock function with given fields: ctx +func (_m *FakeDashboardService) CleanUpDeletedDashboards(ctx context.Context) (int64, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for CleanUpDeletedDashboards") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (int64, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) int64); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } 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) @@ -302,6 +330,36 @@ func (_m *FakeDashboardService) GetDashboardsSharedWithUser(ctx context.Context, return r0, r1 } +// GetSoftDeletedDashboard provides a mock function with given fields: ctx, orgID, uid +func (_m *FakeDashboardService) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*Dashboard, error) { + ret := _m.Called(ctx, orgID, uid) + + if len(ret) == 0 { + panic("no return value specified for GetSoftDeletedDashboard") + } + + var r0 *Dashboard + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string) (*Dashboard, error)); ok { + return rf(ctx, orgID, uid) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, string) *Dashboard); ok { + r0 = rf(ctx, orgID, uid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Dashboard) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { + r1 = rf(ctx, orgID, uid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ImportDashboard provides a mock function with given fields: ctx, dto func (_m *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*Dashboard, error) { ret := _m.Called(ctx, dto) @@ -332,6 +390,24 @@ func (_m *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDa return r0, r1 } +// RestoreDashboard provides a mock function with given fields: ctx, dashboard, user, optionalFolderUID +func (_m *FakeDashboardService) RestoreDashboard(ctx context.Context, dashboard *Dashboard, user identity.Requester, optionalFolderUID string) error { + ret := _m.Called(ctx, dashboard, user, optionalFolderUID) + + if len(ret) == 0 { + panic("no return value specified for RestoreDashboard") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *Dashboard, identity.Requester, string) error); ok { + r0 = rf(ctx, dashboard, user, optionalFolderUID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SaveDashboard provides a mock function with given fields: ctx, dto, allowUiUpdate func (_m *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*Dashboard, error) { ret := _m.Called(ctx, dto, allowUiUpdate) @@ -392,6 +468,24 @@ func (_m *FakeDashboardService) SearchDashboards(ctx context.Context, query *Fin return r0, r1 } +// SoftDeleteDashboard provides a mock function with given fields: ctx, orgID, dashboardUid +func (_m *FakeDashboardService) SoftDeleteDashboard(ctx context.Context, orgID int64, dashboardUid string) error { + ret := _m.Called(ctx, orgID, dashboardUid) + + if len(ret) == 0 { + panic("no return value specified for SoftDeleteDashboard") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string) error); ok { + r0 = rf(ctx, orgID, dashboardUid) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // NewFakeDashboardService creates a new instance of FakeDashboardService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewFakeDashboardService(t interface { diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 12d47739be0..5086ce5ceb8 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -7,8 +7,6 @@ import ( "strings" "time" - "xorm.io/xorm" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" @@ -16,6 +14,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/folder" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -513,6 +512,67 @@ func (d *dashboardStore) GetDashboardsByPluginID(ctx context.Context, query *das } return dashboards, nil } +func (d *dashboardStore) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) { + if orgID == 0 || uid == "" { + return nil, dashboards.ErrDashboardIdentifierNotSet + } + + var queryResult *dashboards.Dashboard + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { + dashboard := dashboards.Dashboard{OrgID: orgID, UID: uid} + has, err := sess.Where("deleted IS NOT NULL").Get(&dashboard) + + if err != nil { + return err + } else if !has { + return dashboards.ErrDashboardNotFound + } + + queryResult = &dashboard + return nil + }) + + return queryResult, err +} + +func (d *dashboardStore) RestoreDashboard(ctx context.Context, orgID int64, dashboardUID string, folder *folder.Folder) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + // nolint:staticcheck + _, err := sess.Exec("UPDATE dashboard SET deleted=NULL, folder_id = ?, folder_uid=? WHERE org_id=? AND uid=?", folder.ID, folder.UID, orgID, dashboardUID) + return err + }) +} + +func (d *dashboardStore) SoftDeleteDashboard(ctx context.Context, orgID int64, dashboardUID string) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + _, err := sess.Exec("UPDATE dashboard SET deleted=? WHERE org_id=? AND uid=?", time.Now(), orgID, dashboardUID) + return err + }) +} + +func (d *dashboardStore) SoftDeleteDashboardsInFolders(ctx context.Context, orgID int64, folderUids []string) error { + if len(folderUids) == 0 { + return nil + } + + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + s := strings.Builder{} + s.WriteString("UPDATE dashboard SET deleted=? WHERE ") + s.WriteString(fmt.Sprintf("folder_uid IN (%s)", strings.Repeat("?,", len(folderUids)-1)+"?")) + s.WriteString(" AND org_id = ? AND is_folder = ?") + + sql := s.String() + args := make([]any, 0, 3) + args = append(args, sql, time.Now()) + for _, folderUID := range folderUids { + args = append(args, folderUID) + } + args = append(args, orgID, d.store.GetDialect().BooleanStr(false)) + + _, err := sess.Exec(args...) + return err + }) +} func (d *dashboardStore) DeleteDashboard(ctx context.Context, cmd *dashboards.DeleteDashboardCommand) error { return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { @@ -545,10 +605,13 @@ func (d *dashboardStore) deleteDashboard(cmd *dashboards.DeleteDashboardCommand, } if dashboard.IsFolder { - deletes = append(deletes, "DELETE FROM dashboard WHERE folder_id = ?") + // if this is a soft delete, we need to skip children deletion. + if !d.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) && !cmd.SkipSoftDeletedDashboards { + deletes = append(deletes, "DELETE FROM dashboard WHERE folder_id = ? AND deleted IS NULL") - if err := d.deleteChildrenDashboardAssociations(sess, &dashboard); err != nil { - return err + if err := d.deleteChildrenDashboardAssociations(sess, &dashboard); err != nil { + return err + } } // remove all access control permission with folder scope @@ -686,7 +749,7 @@ func (d *dashboardStore) GetDashboard(ctx context.Context, query *dashboards.Get metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() } - has, err := sess.MustCols(mustCols...).Nullable("folder_uid").Get(&dashboard) + has, err := sess.Where("deleted IS NULL").MustCols(mustCols...).Nullable("folder_uid").Get(&dashboard) if err != nil { return err } else if !has { @@ -726,17 +789,20 @@ func (d *dashboardStore) GetDashboards(ctx context.Context, query *dashboards.Ge if len(query.DashboardIDs) == 0 && len(query.DashboardUIDs) == 0 { return star.ErrCommandValidationFailed } - var session *xorm.Session + + // remove soft deleted dashboards from the response + sess.Where("deleted IS NULL") + if len(query.DashboardIDs) > 0 { - session = sess.In("id", query.DashboardIDs) + sess.In("id", query.DashboardIDs) } else { - session = sess.In("uid", query.DashboardUIDs) + sess.In("uid", query.DashboardUIDs) } if query.OrgID > 0 { - session = sess.Where("org_id = ?", query.OrgID) + sess.Where("org_id = ?", query.OrgID) } - err := session.Find(&dashboards) + err := sess.Find(&dashboards) return err }) if err != nil { @@ -802,6 +868,8 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F }) } + filters = append(filters, searchstore.DeletedFilter{Deleted: query.IsDeleted}) + var res []dashboards.DashboardSearchProjection sb := &searchstore.Builder{Dialect: d.store.GetDialect(), Filters: filters, Features: d.features} @@ -871,7 +939,7 @@ func (d *dashboardStore) CountDashboardsInFolders( args = append(args, folderUID) } } - s.WriteString(" AND org_id = ? AND is_folder = ?") + s.WriteString(" AND org_id = ? AND is_folder = ? AND deleted IS NULL") args = append(args, req.OrgID, d.store.GetDialect().BooleanStr(false)) sql := s.String() _, err := sess.SQL(sql, args...).Get(&count) @@ -919,6 +987,18 @@ func (d *dashboardStore) GetAllDashboards(ctx context.Context) ([]*dashboards.Da return dashboards, nil } +func (d *dashboardStore) GetSoftDeletedExpiredDashboards(ctx context.Context, duration time.Duration) ([]*dashboards.Dashboard, error) { + var dashboards = make([]*dashboards.Dashboard, 0) + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { + err := sess.Where("deleted IS NOT NULL AND deleted < ?", time.Now().Add(-duration)).Find(&dashboards) + return err + }) + if err != nil { + return nil, err + } + return dashboards, nil +} + func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { limits := "a.Map{} diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index d90c2442d29..4c5fee97da7 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -525,12 +525,7 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { _ = insertTestDashboard(t, dashboardStore, "delete me 1", 1, folder.ID, folder.UID, false, "delete this 1") _ = insertTestDashboard(t, dashboardStore, "delete me 2", 1, folder.ID, folder.UID, false, "delete this 2") - err := dashboardStore.DeleteDashboardsInFolders( - context.Background(), - &dashboards.DeleteDashboardsInFolderRequest{ - FolderUIDs: []string{folder.UID}, - OrgID: 1, - }) + err := dashboardStore.SoftDeleteDashboardsInFolders(context.Background(), folder.OrgID, []string{folder.UID}) require.NoError(t, err) count, err := dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{folder.UID}, OrgID: 1}) @@ -539,6 +534,127 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { }) } +func TestIntegrationGetSoftDeletedDashboard(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + var sqlStore *sqlstore.SQLStore + var cfg *setting.Cfg + var savedFolder, savedDash *dashboards.Dashboard + var dashboardStore dashboards.Store + + setup := func() { + sqlStore, cfg = db.InitTestDBWithCfg(t) + quotaService := quotatest.New(false, nil) + var err error + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) + require.NoError(t, err) + savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod", "webapp") + savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.ID, savedFolder.UID, false, "prod", "webapp") + insertTestDashboard(t, dashboardStore, "test dash 45", 1, savedFolder.ID, savedFolder.UID, false, "prod") + } + + t.Run("Should soft delete a dashboard", func(t *testing.T) { + setup() + + // Confirm there are 2 dashboards in the folder + amount, err := dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{savedFolder.UID}, OrgID: 1}) + require.NoError(t, err) + assert.Equal(t, int64(2), amount) + + // Soft delete the dashboard + err = dashboardStore.SoftDeleteDashboard(context.Background(), savedDash.OrgID, savedDash.UID) + require.NoError(t, err) + + // There is only 1 dashboard in the folder after soft delete + amount, err = dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{savedFolder.UID}, OrgID: 1}) + require.NoError(t, err) + assert.Equal(t, int64(1), amount) + + var dash *dashboards.Dashboard + // Get the soft deleted dashboard should be empty + dash, _ = dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{UID: savedDash.UID, OrgID: savedDash.OrgID}) + assert.Error(t, dashboards.ErrDashboardNotFound) + assert.Nil(t, dash) + + // Get the soft deleted dashboard + dash, err = dashboardStore.GetSoftDeletedDashboard(context.Background(), savedDash.OrgID, savedDash.UID) + require.NoError(t, err) + assert.Equal(t, savedDash.ID, dash.ID) + assert.Equal(t, savedDash.UID, dash.UID) + assert.Equal(t, savedDash.Title, dash.Title) + }) + + t.Run("Should not fail when trying to soft delete a soft deleted dashboard", func(t *testing.T) { + setup() + + // Soft delete the dashboard + err := dashboardStore.SoftDeleteDashboard(context.Background(), savedDash.OrgID, savedDash.UID) + require.NoError(t, err) + + // Soft delete the dashboard + err = dashboardStore.SoftDeleteDashboard(context.Background(), savedDash.OrgID, savedDash.UID) + require.NoError(t, err) + + // Get the soft deleted dashboard + dash, err := dashboardStore.GetSoftDeletedDashboard(context.Background(), savedDash.OrgID, savedDash.UID) + require.NoError(t, err) + assert.Equal(t, savedDash.ID, dash.ID) + assert.Equal(t, savedDash.UID, dash.UID) + assert.Equal(t, savedDash.Title, dash.Title) + }) + + t.Run("Should restore a dashboard", func(t *testing.T) { + setup() + + // Confirm there are 2 dashboards in the folder + amount, err := dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{savedFolder.UID}, OrgID: 1}) + require.NoError(t, err) + assert.Equal(t, int64(2), amount) + + // Soft delete the dashboard + err = dashboardStore.SoftDeleteDashboard(context.Background(), savedDash.OrgID, savedDash.UID) + require.NoError(t, err) + + // There is only 1 dashboard in the folder after soft delete + amount, err = dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{savedFolder.UID}, OrgID: 1}) + require.NoError(t, err) + assert.Equal(t, int64(1), amount) + + // Get the soft deleted dashboard + dash, err := dashboardStore.GetSoftDeletedDashboard(context.Background(), savedDash.OrgID, savedDash.UID) + require.NoError(t, err) + assert.Equal(t, savedDash.ID, dash.ID) + assert.Equal(t, savedDash.UID, dash.UID) + assert.Equal(t, savedDash.Title, dash.Title) + + // Restore deleted dashboard + // nolint:staticcheck + err = dashboardStore.RestoreDashboard(context.Background(), savedDash.OrgID, savedDash.UID, &folder.Folder{ID: savedDash.FolderID, UID: savedDash.FolderUID}) + require.NoError(t, err) + + // Restore increases the amount of dashboards in the folder + amount, err = dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{savedFolder.UID}, OrgID: 1}) + require.NoError(t, err) + assert.Equal(t, int64(2), amount) + + // Get the soft deleted dashboard should be empty + dash, err = dashboardStore.GetSoftDeletedDashboard(context.Background(), savedDash.OrgID, savedDash.UID) + assert.Error(t, err) + assert.Nil(t, dash) + + // Get the restored dashboard + dash, err = dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{UID: savedDash.UID, OrgID: savedDash.OrgID}) + require.NoError(t, err) + assert.Equal(t, savedDash.ID, dash.ID) + assert.Equal(t, savedDash.UID, dash.UID) + assert.Equal(t, savedDash.Title, dash.Title) + // nolint:staticcheck + assert.Equal(t, savedDash.FolderID, dash.FolderID) + assert.Equal(t, savedDash.FolderUID, dash.FolderUID) + }) +} + func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/dashboards/errors.go b/pkg/services/dashboards/errors.go index 307f18fc50e..e7b402fe69a 100644 --- a/pkg/services/dashboards/errors.go +++ b/pkg/services/dashboards/errors.go @@ -116,6 +116,11 @@ var ( StatusCode: 404, Status: "not-found", } + ErrFolderRestoreNotFound = DashboardErr{ + Reason: "Restoring folder not found", + StatusCode: 400, + Status: "bad-request", + } ErrFolderNotFound = errors.New("folder not found") ErrFolderVersionMismatch = errors.New("the folder has been changed by someone else") diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 4df6dc95153..97d44731848 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -36,6 +36,7 @@ type Dashboard struct { Created time.Time Updated time.Time + Deleted time.Time UpdatedBy int64 CreatedBy int64 @@ -209,6 +210,10 @@ type SaveDashboardCommand struct { UpdatedAt time.Time } +type RestoreDeletedDashboardCommand struct { + FolderUID string `json:"folderUid" xorm:"folder_uid"` +} + type DashboardProvisioning struct { ID int64 `xorm:"pk autoincr 'id'"` DashboardID int64 `xorm:"dashboard_id"` @@ -219,10 +224,11 @@ type DashboardProvisioning struct { } type DeleteDashboardCommand struct { - ID int64 - UID string - OrgID int64 - ForceDeleteFolderRules bool + ID int64 + UID string + OrgID int64 + ForceDeleteFolderRules bool + SkipSoftDeletedDashboards bool } type DeleteOrphanedProvisionedDashboardsCommand struct { @@ -307,6 +313,7 @@ type DashboardSearchProjection struct { FolderSlug string FolderTitle string SortMeta int64 + Deleted *time.Time } const ( @@ -414,6 +421,7 @@ type FindPersistedDashboardsQuery struct { Page int64 Permission dashboardaccess.PermissionType Sort model.SortOption + IsDeleted bool Filters []any } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 1f149a67928..2b965af4693 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "strings" "time" @@ -41,6 +42,8 @@ var ( _ dashboards.DashboardService = (*DashboardServiceImpl)(nil) _ dashboards.DashboardProvisioningService = (*DashboardServiceImpl)(nil) _ dashboards.PluginService = (*DashboardServiceImpl)(nil) + + daysInTrash = 24 * 30 * time.Hour ) type DashboardServiceImpl struct { @@ -367,6 +370,61 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar return dash, nil } +func (dr *DashboardServiceImpl) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) { + return dr.dashboardStore.GetSoftDeletedDashboard(ctx, orgID, uid) +} + +func (dr *DashboardServiceImpl) RestoreDashboard(ctx context.Context, dashboard *dashboards.Dashboard, user identity.Requester, optionalFolderUID string) error { + if !dr.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { + return fmt.Errorf("feature flag %s is not enabled", featuremgmt.FlagDashboardRestore) + } + + // if the optionalFolder is provided we need to check if the folder exists and user has access to it + if optionalFolderUID != "" { + restoringFolder, err := dr.folderService.Get(ctx, &folder.GetFolderQuery{ + UID: &optionalFolderUID, + OrgID: dashboard.OrgID, + SignedInUser: user, + }) + if err != nil { + if errors.Is(err, dashboards.ErrFolderNotFound) { + return dashboards.ErrFolderRestoreNotFound + } + return folder.ErrInternal.Errorf("failed to fetch parent folder from store: %w", err) + } + + return dr.dashboardStore.RestoreDashboard(ctx, dashboard.OrgID, dashboard.UID, restoringFolder) + } + + // if the optionalFolder is not provided we need to restore the dashboard to the original folder + // we check for permissions and the folder existence before restoring + restoringFolder, err := dr.folderService.Get(ctx, &folder.GetFolderQuery{ + UID: &dashboard.FolderUID, + OrgID: dashboard.OrgID, + SignedInUser: user, + }) + if err != nil { + if errors.Is(err, dashboards.ErrFolderNotFound) { + return dashboards.ErrFolderRestoreNotFound + } + return folder.ErrInternal.Errorf("failed to fetch parent folder from store: %w", err) + } + + return dr.dashboardStore.RestoreDashboard(ctx, dashboard.OrgID, dashboard.UID, restoringFolder) +} + +func (dr *DashboardServiceImpl) SoftDeleteDashboard(ctx context.Context, orgID int64, dashboardUID string) error { + if !dr.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { + return fmt.Errorf("feature flag %s is not enabled", featuremgmt.FlagDashboardRestore) + } + + provisionedData, _ := dr.GetProvisionedDashboardDataByDashboardUID(ctx, orgID, dashboardUID) + if provisionedData != nil && provisionedData.ID != 0 { + return dashboards.ErrDashboardCannotDeleteProvisionedDashboard + } + + return dr.dashboardStore.SoftDeleteDashboard(ctx, orgID, dashboardUID) +} // DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for // operations by the user where we want to make sure user does not delete provisioned dashboard. @@ -676,6 +734,9 @@ func makeQueryResult(query *dashboards.FindPersistedDashboardsQuery, res []dashb if len(item.Term) > 0 { hit.Tags = append(hit.Tags, item.Term) } + if item.Deleted != nil { + hit.RemainingTrashAtAge = util.RemainingDaysUntil((*item.Deleted).Add(daysInTrash)) + } } return hitList } @@ -689,7 +750,29 @@ func (dr DashboardServiceImpl) CountInFolders(ctx context.Context, orgID int64, } func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) error { + if dr.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { + return dr.dashboardStore.SoftDeleteDashboardsInFolders(ctx, orgID, folderUIDs) + } + return dr.dashboardStore.DeleteDashboardsInFolders(ctx, &dashboards.DeleteDashboardsInFolderRequest{FolderUIDs: folderUIDs, OrgID: orgID}) } func (dr *DashboardServiceImpl) Kind() string { return entity.StandardKindDashboard } + +func (dr *DashboardServiceImpl) CleanUpDeletedDashboards(ctx context.Context) (int64, error) { + var deletedDashboardsCount int64 + deletedDashboards, err := dr.dashboardStore.GetSoftDeletedExpiredDashboards(ctx, daysInTrash) + if err != nil { + return 0, err + } + for _, dashboard := range deletedDashboards { + err = dr.DeleteDashboard(ctx, dashboard.ID, dashboard.OrgID) + if err != nil { + dr.log.Warn("Failed to cleanup deleted dashboard", "dashboardUid", dashboard.UID, "error", err) + break + } + deletedDashboardsCount++ + } + + return deletedDashboardsCount, nil +} diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index b13f0bbb666..78e81ce47f2 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/log" "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/folder/foldertest" "github.com/grafana/grafana/pkg/services/guardian" @@ -30,6 +31,7 @@ func TestDashboardService(t *testing.T) { log: log.New("test.logger"), dashboardStore: &fakeStore, folderService: folderSvc, + features: featuremgmt.WithFeatures(), } origNewDashboardGuardian := guardian.New @@ -212,5 +214,12 @@ func TestDashboardService(t *testing.T) { err := service.DeleteInFolders(context.Background(), 1, []string{"uid"}, nil) require.NoError(t, err) }) + + t.Run("Soft Delete dashboards in folder", func(t *testing.T) { + service.features = featuremgmt.WithFeatures(featuremgmt.FlagDashboardRestore) + fakeStore.On("SoftDeleteDashboardsInFolders", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + err := service.DeleteInFolders(context.Background(), 1, []string{"uid"}, nil) + require.NoError(t, err) + }) }) } diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 2a5b079e7ef..c690918bdcc 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -1,12 +1,16 @@ -// Code generated by mockery v2.40.1. DO NOT EDIT. +// Code generated by mockery v2.42.2. DO NOT EDIT. package dashboards import ( context "context" - quota "github.com/grafana/grafana/pkg/services/quota" + folder "github.com/grafana/grafana/pkg/services/folder" mock "github.com/stretchr/testify/mock" + + quota "github.com/grafana/grafana/pkg/services/quota" + + time "time" ) // FakeDashboardStore is an autogenerated mock type for the Store type @@ -426,6 +430,84 @@ func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(ctx context.Conte return r0, r1 } +// GetSoftDeletedDashboard provides a mock function with given fields: ctx, orgID, uid +func (_m *FakeDashboardStore) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*Dashboard, error) { + ret := _m.Called(ctx, orgID, uid) + + if len(ret) == 0 { + panic("no return value specified for GetSoftDeletedDashboard") + } + + var r0 *Dashboard + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string) (*Dashboard, error)); ok { + return rf(ctx, orgID, uid) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, string) *Dashboard); ok { + r0 = rf(ctx, orgID, uid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Dashboard) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { + r1 = rf(ctx, orgID, uid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetSoftDeletedExpiredDashboards provides a mock function with given fields: ctx, duration +func (_m *FakeDashboardStore) GetSoftDeletedExpiredDashboards(ctx context.Context, duration time.Duration) ([]*Dashboard, error) { + ret := _m.Called(ctx, duration) + + if len(ret) == 0 { + panic("no return value specified for GetSoftDeletedExpiredDashboards") + } + + var r0 []*Dashboard + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, time.Duration) ([]*Dashboard, error)); ok { + return rf(ctx, duration) + } + if rf, ok := ret.Get(0).(func(context.Context, time.Duration) []*Dashboard); ok { + r0 = rf(ctx, duration) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*Dashboard) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, time.Duration) error); ok { + r1 = rf(ctx, duration) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RestoreDashboard provides a mock function with given fields: ctx, orgID, dashboardUid, _a3 +func (_m *FakeDashboardStore) RestoreDashboard(ctx context.Context, orgID int64, dashboardUid string, _a3 *folder.Folder) error { + ret := _m.Called(ctx, orgID, dashboardUid, _a3) + + if len(ret) == 0 { + panic("no return value specified for RestoreDashboard") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string, *folder.Folder) error); ok { + r0 = rf(ctx, orgID, dashboardUid, _a3) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SaveDashboard provides a mock function with given fields: ctx, cmd func (_m *FakeDashboardStore) SaveDashboard(ctx context.Context, cmd SaveDashboardCommand) (*Dashboard, error) { ret := _m.Called(ctx, cmd) @@ -486,6 +568,42 @@ func (_m *FakeDashboardStore) SaveProvisionedDashboard(ctx context.Context, cmd return r0, r1 } +// SoftDeleteDashboard provides a mock function with given fields: ctx, orgID, dashboardUid +func (_m *FakeDashboardStore) SoftDeleteDashboard(ctx context.Context, orgID int64, dashboardUid string) error { + ret := _m.Called(ctx, orgID, dashboardUid) + + if len(ret) == 0 { + panic("no return value specified for SoftDeleteDashboard") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string) error); ok { + r0 = rf(ctx, orgID, dashboardUid) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SoftDeleteDashboardsInFolders provides a mock function with given fields: ctx, orgID, folderUids +func (_m *FakeDashboardStore) SoftDeleteDashboardsInFolders(ctx context.Context, orgID int64, folderUids []string) error { + ret := _m.Called(ctx, orgID, folderUids) + + if len(ret) == 0 { + panic("no return value specified for SoftDeleteDashboardsInFolders") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, []string) error); ok { + r0 = rf(ctx, orgID, folderUids) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // UnprovisionDashboard provides a mock function with given fields: ctx, id func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error { ret := _m.Called(ctx, id) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 9b4ec6eac77..7a78c21a9ac 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1268,6 +1268,14 @@ var ( Owner: grafanaFrontendPlatformSquad, FrontendOnly: false, }, + { + Name: "dashboardRestore", + Description: "Enables deleted dashboard restore feature", + Stage: FeatureStageExperimental, + Owner: grafanaFrontendPlatformSquad, + HideFromDocs: true, + HideFromAdminPage: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index d56184b50cd..eab9d2405ae 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -169,3 +169,4 @@ autofixDSUID,experimental,@grafana/plugins-platform-backend,false,false,false logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true newDashboardSharingComponent,experimental,@grafana/sharing-squad,false,false,true notificationBanner,experimental,@grafana/grafana-frontend-platform,false,false,false +dashboardRestore,experimental,@grafana/grafana-frontend-platform,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f5ee8152d88..8b2c3614b9b 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -686,4 +686,8 @@ const ( // FlagNotificationBanner // Enables the notification banner UI and API FlagNotificationBanner = "notificationBanner" + + // FlagDashboardRestore + // Enables deleted dashboard restore feature + FlagDashboardRestore = "dashboardRestore" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 02fc2a385d2..bb5e849b531 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2206,6 +2206,20 @@ "stage": "experimental", "codeowner": "@grafana/search-and-storage" } + }, + { + "metadata": { + "name": "dashboardRestore", + "resourceVersion": "1708455041047", + "creationTimestamp": "2024-02-20T18:50:41Z" + }, + "spec": { + "description": "Enables deleted dashboard restore feature", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "hideFromAdminPage": true, + "hideFromDocs": true + } } ] -} \ No newline at end of file +} diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 23095d13768..6f9f7cb560b 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -852,10 +852,16 @@ func (s *Service) deleteChildrenInFolder(ctx context.Context, orgID int64, folde } func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderCommand, folderUIDs []string) error { + if s.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { + if err := s.dashboardStore.SoftDeleteDashboardsInFolders(ctx, cmd.OrgID, folderUIDs); err != nil { + return toFolderError(err) + } + } // TODO use bulk delete for _, folderUID := range folderUIDs { - deleteCmd := dashboards.DeleteDashboardCommand{OrgID: cmd.OrgID, UID: folderUID, ForceDeleteFolderRules: cmd.ForceDeleteRules} - + // only hard delete the folder representation in the dashboard store + // nolint:staticcheck + deleteCmd := dashboards.DeleteDashboardCommand{OrgID: cmd.OrgID, UID: folderUID, ForceDeleteFolderRules: cmd.ForceDeleteRules, SkipSoftDeletedDashboards: true} if err := s.dashboardStore.DeleteDashboard(ctx, &deleteCmd); err != nil { return toFolderError(err) } diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 37a9cfba5af..e11a5c031e9 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -280,7 +280,68 @@ func TestIntegrationFolderService(t *testing.T) { require.Equal(t, expectedForceDeleteRules, actualCmd.ForceDeleteFolderRules) }) + t.Run("When deleting folder by uid, expectedForceDeleteRules as false, and dashboard Restore turned on should not return access denied error", func(t *testing.T) { + f := folder.NewFolder(util.GenerateShortUID(), "") + f.UID = util.GenerateShortUID() + folderStore.On("GetFolderByUID", mock.Anything, orgID, f.UID).Return(f, nil) + + var actualCmd *dashboards.DeleteDashboardCommand + dashStore.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + actualCmd = args.Get(1).(*dashboards.DeleteDashboardCommand) + }).Return(nil).Once() + service.features = featuremgmt.WithFeatures(featuremgmt.FlagDashboardRestore) + + var folderUids []string + dashStore.On("SoftDeleteDashboardsInFolders", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + folderUids = args.Get(2).([]string) + }).Return(nil).Once() + + expectedForceDeleteRules := false + err := service.Delete(context.Background(), &folder.DeleteFolderCommand{ + UID: f.UID, + OrgID: orgID, + ForceDeleteRules: expectedForceDeleteRules, + SignedInUser: usr, + }) + require.NoError(t, err) + require.NotNil(t, actualCmd) + require.Equal(t, orgID, actualCmd.OrgID) + require.Equal(t, expectedForceDeleteRules, actualCmd.ForceDeleteFolderRules) + require.Equal(t, f.UID, folderUids[0]) + }) + + t.Run("When deleting folder by uid, expectedForceDeleteRules as true, and dashboard Restore turned on should not return access denied error", func(t *testing.T) { + f := folder.NewFolder(util.GenerateShortUID(), "") + f.UID = util.GenerateShortUID() + folderStore.On("GetFolderByUID", mock.Anything, orgID, f.UID).Return(f, nil) + + var actualCmd *dashboards.DeleteDashboardCommand + dashStore.On("DeleteDashboard", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + actualCmd = args.Get(1).(*dashboards.DeleteDashboardCommand) + }).Return(nil).Once() + service.features = featuremgmt.WithFeatures(featuremgmt.FlagDashboardRestore) + + var folderUids []string + dashStore.On("SoftDeleteDashboardsInFolders", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + folderUids = args.Get(2).([]string) + }).Return(nil).Once() + + expectedForceDeleteRules := true + err := service.Delete(context.Background(), &folder.DeleteFolderCommand{ + UID: f.UID, + OrgID: orgID, + ForceDeleteRules: expectedForceDeleteRules, + SignedInUser: usr, + }) + require.NoError(t, err) + require.NotNil(t, actualCmd) + require.Equal(t, orgID, actualCmd.OrgID) + require.Equal(t, expectedForceDeleteRules, actualCmd.ForceDeleteFolderRules) + require.Equal(t, f.UID, folderUids[0]) + }) + t.Cleanup(func() { + service.features = featuremgmt.WithFeatures() guardian.New = origNewGuardian }) }) diff --git a/pkg/services/search/model/model.go b/pkg/services/search/model/model.go index 1d87154dee0..5e3658c35a1 100644 --- a/pkg/services/search/model/model.go +++ b/pkg/services/search/model/model.go @@ -62,22 +62,22 @@ const ( ) type Hit struct { - ID int64 `json:"id"` - UID string `json:"uid"` - Title string `json:"title"` - URI string `json:"uri"` - URL string `json:"url"` - Slug string `json:"slug"` - Type HitType `json:"type"` - Tags []string `json:"tags"` - IsStarred bool `json:"isStarred"` - // Deprecated: use FolderUID instead - FolderID int64 `json:"folderId,omitempty"` - FolderUID string `json:"folderUid,omitempty"` - FolderTitle string `json:"folderTitle,omitempty"` - FolderURL string `json:"folderUrl,omitempty"` - SortMeta int64 `json:"sortMeta"` - SortMetaName string `json:"sortMetaName,omitempty"` + ID int64 `json:"id"` + UID string `json:"uid"` + Title string `json:"title"` + URI string `json:"uri"` + URL string `json:"url"` + Slug string `json:"slug"` + Type HitType `json:"type"` + Tags []string `json:"tags"` + IsStarred bool `json:"isStarred"` + FolderID int64 `json:"folderId,omitempty"` // Deprecated: use FolderUID instead + FolderUID string `json:"folderUid,omitempty"` + FolderTitle string `json:"folderTitle,omitempty"` + FolderURL string `json:"folderUrl,omitempty"` + SortMeta int64 `json:"sortMeta"` + SortMetaName string `json:"sortMetaName,omitempty"` + RemainingTrashAtAge string `json:"remainingTrashAtAge,omitempty"` } type HitList []*Hit diff --git a/pkg/services/search/service.go b/pkg/services/search/service.go index 9b5d9e0bbe6..c59ab22e5a5 100644 --- a/pkg/services/search/service.go +++ b/pkg/services/search/service.go @@ -36,6 +36,7 @@ type Query struct { Limit int64 Page int64 IsStarred bool + IsDeleted bool Type string DashboardUIDs []string DashboardIds []int64 @@ -93,6 +94,7 @@ func (s *SearchService) SearchHandler(ctx context.Context, query *Query) (model. Limit: query.Limit, Page: query.Page, Permission: query.Permission, + IsDeleted: query.IsDeleted, } if sortOpt, exists := s.sortOptions[query.Sort]; exists { diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index 6b4a9f003f7..bf19b56277d 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -234,4 +234,13 @@ func addDashboardMigration(mg *Migrator) { mg.AddMigration("Add isPublic for dashboard", NewAddColumnMigration(dashboardV2, &Column{ Name: "is_public", Type: DB_Bool, Nullable: false, Default: "0", })) + + mg.AddMigration("Add deleted for dashboard", NewAddColumnMigration(dashboardV2, &Column{ + Name: "deleted", Type: DB_DateTime, Nullable: true, + })) + + mg.AddMigration("Add index for deleted", NewAddIndexMigration(dashboardV2, &Index{ + Cols: []string{"deleted"}, + Type: IndexType, + })) } diff --git a/pkg/services/sqlstore/searchstore/builder.go b/pkg/services/sqlstore/searchstore/builder.go index d086b97a4b5..fc318ffe0e0 100644 --- a/pkg/services/sqlstore/searchstore/builder.go +++ b/pkg/services/sqlstore/searchstore/builder.go @@ -66,6 +66,7 @@ func (b *Builder) buildSelect() { dashboard_tag.term, dashboard.is_folder, dashboard.folder_id, + dashboard.deleted, folder.uid AS folder_uid, `) if b.Features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) { diff --git a/pkg/services/sqlstore/searchstore/filters.go b/pkg/services/sqlstore/searchstore/filters.go index 8ba509e7169..825585641fb 100644 --- a/pkg/services/sqlstore/searchstore/filters.go +++ b/pkg/services/sqlstore/searchstore/filters.go @@ -198,3 +198,15 @@ var _ model.FilterWhere = &FolderWithAlertsFilter{} func (f FolderWithAlertsFilter) Where() (string, []any) { return "EXISTS (SELECT 1 FROM alert_rule WHERE alert_rule.namespace_uid = dashboard.uid)", nil } + +type DeletedFilter struct { + Deleted bool +} + +func (f DeletedFilter) Where() (string, []any) { + if f.Deleted { + return "dashboard.deleted IS NOT NULL", nil + } + + return "dashboard.deleted IS NULL", nil +} diff --git a/pkg/util/strings.go b/pkg/util/strings.go index 44c5dcd4eb0..9bb688e3ca5 100644 --- a/pkg/util/strings.go +++ b/pkg/util/strings.go @@ -113,6 +113,21 @@ func GetAgeString(t time.Time) string { return "< 1 minute" } +func RemainingDaysUntil(expiration time.Time) string { + currentTime := time.Now() + durationUntil := expiration.Sub(currentTime) + + daysUntil := int(durationUntil.Hours() / 24) + + if daysUntil == 0 { + return "Today" + } else if daysUntil == 1 { + return "Tomorrow" + } else { + return fmt.Sprintf("%d days", daysUntil) + } +} + // ToCamelCase changes kebab case, snake case or mixed strings to camel case. See unit test for examples. func ToCamelCase(str string) string { var finalParts []string diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index e1e2b3f9504..6a0ec28d6e8 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -4598,7 +4598,6 @@ "type": "object", "properties": { "folderId": { - "description": "Deprecated: use FolderUID instead", "type": "integer", "format": "int64" }, @@ -4618,6 +4617,9 @@ "isStarred": { "type": "boolean" }, + "remainingTrashAtAge": { + "type": "string" + }, "slug": { "type": "string" }, @@ -6387,6 +6389,14 @@ } } }, + "RestoreDeletedDashboardCommand": { + "type": "object", + "properties": { + "folderUid": { + "type": "string" + } + } + }, "RevokeAuthTokenCmd": { "type": "object", "properties": { @@ -8401,17 +8411,11 @@ "schema": { "type": "object", "required": [ - "id", + "uid", "title", "message" ], "properties": { - "id": { - "description": "ID Identifier of the deleted dashboard.", - "type": "integer", - "format": "int64", - "example": 65 - }, "message": { "description": "Message Message of the deleted dashboard.", "type": "string", @@ -8421,6 +8425,11 @@ "description": "Title Title of the deleted dashboard.", "type": "string", "example": "My Dashboard" + }, + "uid": { + "description": "UID Identifier of the deleted dashboard.", + "type": "string", + "example": "65" } } } diff --git a/public/api-merged.json b/public/api-merged.json index bea49171c9f..ba19f54fdbc 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -3316,6 +3316,84 @@ } } }, + "/dashboards/uid/{uid}/trash": { + "delete": { + "description": "Will delete the dashboard given the specified unique identifier (uid).", + "tags": [ + "dashboards" + ], + "summary": "Hard delete dashboard by uid.", + "operationId": "hardDeleteDashboardByUID", + "parameters": [ + { + "type": "string", + "name": "uid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/deleteDashboardResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "patch": { + "tags": [ + "dashboards" + ], + "summary": "Restore a dashboard to a given dashboard version using UID.", + "operationId": "restoreDeletedDashboardByUID", + "parameters": [ + { + "type": "string", + "name": "uid", + "in": "path", + "required": true + }, + { + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RestoreDeletedDashboardCommand" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/postDashboardResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/dashboards/uid/{uid}/versions": { "get": { "tags": [ @@ -8089,6 +8167,12 @@ "description": "Sort method; for listing all the possible sort methods use the search sorting endpoint.", "name": "sort", "in": "query" + }, + { + "type": "boolean", + "description": "Flag indicating if only soft deleted Dashboards should be returned", + "name": "deleted", + "in": "query" } ], "responses": { @@ -15480,7 +15564,6 @@ "type": "object", "properties": { "folderId": { - "description": "Deprecated: use FolderUID instead", "type": "integer", "format": "int64" }, @@ -15500,6 +15583,9 @@ "isStarred": { "type": "boolean" }, + "remainingTrashAtAge": { + "type": "string" + }, "slug": { "type": "string" }, @@ -18521,6 +18607,14 @@ } } }, + "RestoreDeletedDashboardCommand": { + "type": "object", + "properties": { + "folderUid": { + "type": "string" + } + } + }, "RevokeAuthTokenCmd": { "type": "object", "properties": { @@ -22144,17 +22238,11 @@ "schema": { "type": "object", "required": [ - "id", + "uid", "title", "message" ], "properties": { - "id": { - "description": "ID Identifier of the deleted dashboard.", - "type": "integer", - "format": "int64", - "example": 65 - }, "message": { "description": "Message Message of the deleted dashboard.", "type": "string", @@ -22164,6 +22252,11 @@ "description": "Title Title of the deleted dashboard.", "type": "string", "example": "My Dashboard" + }, + "uid": { + "description": "UID Identifier of the deleted dashboard.", + "type": "string", + "example": "65" } } } diff --git a/public/openapi3.json b/public/openapi3.json index 8648f538b5d..0f07e58e32d 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -488,12 +488,6 @@ "application/json": { "schema": { "properties": { - "id": { - "description": "ID Identifier of the deleted dashboard.", - "example": 65, - "format": "int64", - "type": "integer" - }, "message": { "description": "Message Message of the deleted dashboard.", "example": "Dashboard My Dashboard deleted", @@ -503,10 +497,15 @@ "description": "Title Title of the deleted dashboard.", "example": "My Dashboard", "type": "string" + }, + "uid": { + "description": "UID Identifier of the deleted dashboard.", + "example": "65", + "type": "string" } }, "required": [ - "id", + "uid", "title", "message" ], @@ -6126,7 +6125,6 @@ "Hit": { "properties": { "folderId": { - "description": "Deprecated: use FolderUID instead", "format": "int64", "type": "integer" }, @@ -6146,6 +6144,9 @@ "isStarred": { "type": "boolean" }, + "remainingTrashAtAge": { + "type": "string" + }, "slug": { "type": "string" }, @@ -9168,6 +9169,14 @@ }, "type": "object" }, + "RestoreDeletedDashboardCommand": { + "properties": { + "folderUid": { + "type": "string" + } + }, + "type": "object" + }, "RevokeAuthTokenCmd": { "properties": { "authTokenId": { @@ -16011,6 +16020,91 @@ ] } }, + "/dashboards/uid/{uid}/trash": { + "delete": { + "description": "Will delete the dashboard given the specified unique identifier (uid).", + "operationId": "hardDeleteDashboardByUID", + "parameters": [ + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/deleteDashboardResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Hard delete dashboard by uid.", + "tags": [ + "dashboards" + ] + }, + "patch": { + "operationId": "restoreDeletedDashboardByUID", + "parameters": [ + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreDeletedDashboardCommand" + } + } + }, + "required": true, + "x-originalParamName": "Body" + }, + "responses": { + "200": { + "$ref": "#/components/responses/postDashboardResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Restore a dashboard to a given dashboard version using UID.", + "tags": [ + "dashboards" + ] + } + }, "/dashboards/uid/{uid}/versions": { "get": { "operationId": "getDashboardVersionsByUID", @@ -21163,6 +21257,14 @@ ], "type": "string" } + }, + { + "description": "Flag indicating if only soft deleted Dashboards should be returned", + "in": "query", + "name": "deleted", + "schema": { + "type": "boolean" + } } ], "responses": {