mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Dashboards: Add feature restore dashboards backend (#83131)
Co-authored-by: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com>
This commit is contained in:
parent
edae5fc791
commit
42d75ac737
@ -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`
|
||||
|
@ -188,4 +188,5 @@ export interface FeatureToggles {
|
||||
logsExploreTableDefaultVisualization?: boolean;
|
||||
newDashboardSharingComponent?: boolean;
|
||||
notificationBanner?: boolean;
|
||||
dashboardRestore?: boolean;
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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{}
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
|
@ -686,4 +686,8 @@ const (
|
||||
// FlagNotificationBanner
|
||||
// Enables the notification banner UI and API
|
||||
FlagNotificationBanner = "notificationBanner"
|
||||
|
||||
// FlagDashboardRestore
|
||||
// Enables deleted dashboard restore feature
|
||||
FlagDashboardRestore = "dashboardRestore"
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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": {
|
||||
|
Loading…
Reference in New Issue
Block a user