Dashboards: Add feature restore dashboards backend (#83131)

Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com>
This commit is contained in:
Ezequiel Victorero 2024-05-16 14:36:26 -03:00 committed by GitHub
parent edae5fc791
commit 42d75ac737
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1230 additions and 96 deletions

View File

@ -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:*`<br>`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:*`<br>`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`

View File

@ -188,4 +188,5 @@ export interface FeatureToggles {
logsExploreTableDefaultVisualization?: boolean;
newDashboardSharingComponent?: boolean;
notificationBanner?: boolean;
dashboardRestore?: boolean;
}

View File

@ -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))

View File

@ -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
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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 := &quota.Map{}

View File

@ -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")

View File

@ -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")

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
})
}

View File

@ -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)

View File

@ -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,
},
}
)

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
169 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
170 newDashboardSharingComponent experimental @grafana/sharing-squad false false true
171 notificationBanner experimental @grafana/grafana-frontend-platform false false false
172 dashboardRestore experimental @grafana/grafana-frontend-platform false false false

View File

@ -686,4 +686,8 @@ const (
// FlagNotificationBanner
// Enables the notification banner UI and API
FlagNotificationBanner = "notificationBanner"
// FlagDashboardRestore
// Enables deleted dashboard restore feature
FlagDashboardRestore = "dashboardRestore"
)

View File

@ -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
}
}
]
}
}

View File

@ -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)
}

View File

@ -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
})
})

View File

@ -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

View File

@ -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 {

View File

@ -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,
}))
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

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