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": {