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
|
- **403** – Access denied
|
||||||
- **404** – Not found
|
- **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
|
## Gets the home dashboard
|
||||||
|
|
||||||
`GET /api/dashboards/home`
|
`GET /api/dashboards/home`
|
||||||
|
@ -188,4 +188,5 @@ export interface FeatureToggles {
|
|||||||
logsExploreTableDefaultVisualization?: boolean;
|
logsExploreTableDefaultVisualization?: boolean;
|
||||||
newDashboardSharingComponent?: boolean;
|
newDashboardSharingComponent?: boolean;
|
||||||
notificationBanner?: boolean;
|
notificationBanner?: boolean;
|
||||||
|
dashboardRestore?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -456,11 +456,23 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
// Dashboard
|
// Dashboard
|
||||||
apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) {
|
apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) {
|
||||||
dashboardRoute.Get("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsRead)), routing.Wrap(hs.GetDashboard))
|
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) {
|
dashboardRoute.Group("/uid/:uid", func(dashUidRoute routing.RouteRegister) {
|
||||||
dashUidRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersions))
|
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.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.RestoreDashboardVersion))
|
||||||
dashUidRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersion))
|
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) {
|
dashUidRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) {
|
||||||
dashboardPermissionRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionDashboardsPermissionsRead)), routing.Wrap(hs.GetDashboardPermissionList))
|
dashboardPermissionRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionDashboardsPermissionsRead)), routing.Wrap(hs.GetDashboardPermissionList))
|
||||||
dashboardPermissionRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionDashboardsPermissionsWrite)), routing.Wrap(hs.UpdateDashboardPermissions))
|
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 {
|
func dashboardGuardianResponse(err error) response.Response {
|
||||||
if err != nil {
|
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.StatusInternalServerError, "Error while checking dashboard permissions", err)
|
||||||
}
|
}
|
||||||
return response.Error(http.StatusForbidden, "Access denied to this dashboard", nil)
|
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
|
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
|
// DeleteDashboardByUID swagger:route DELETE /dashboards/uid/{uid} dashboards deleteDashboardByUID
|
||||||
//
|
//
|
||||||
// Delete dashboard by uid.
|
// Delete dashboard by uid.
|
||||||
@ -288,11 +387,40 @@ func (hs *HTTPServer) DeleteDashboardByUID(c *contextmodel.ReqContext) response.
|
|||||||
return hs.deleteDashboard(c)
|
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 {
|
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"])
|
uid := web.Params(c.Req)[":uid"]
|
||||||
if rsp != nil {
|
|
||||||
return rsp
|
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)
|
guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Err(err)
|
return response.Err(err)
|
||||||
@ -346,7 +474,7 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo
|
|||||||
return response.JSON(http.StatusOK, util.DynMap{
|
return response.JSON(http.StatusOK, util.DynMap{
|
||||||
"title": dash.Title,
|
"title": dash.Title,
|
||||||
"message": fmt.Sprintf("Dashboard %s deleted", 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"`
|
UID string `json:"uid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swagger:parameters hardDeleteDashboardByUID
|
||||||
|
type HardDeleteDashboardByUIDParams struct {
|
||||||
|
// in:path
|
||||||
|
// required:true
|
||||||
|
UID string `json:"uid"`
|
||||||
|
}
|
||||||
|
|
||||||
// swagger:parameters postDashboard
|
// swagger:parameters postDashboard
|
||||||
type PostDashboardParams struct {
|
type PostDashboardParams struct {
|
||||||
// in:body
|
// in:body
|
||||||
@ -1133,10 +1268,10 @@ type DeleteDashboardResponse struct {
|
|||||||
// The response message
|
// The response message
|
||||||
// in: body
|
// in: body
|
||||||
Body struct {
|
Body struct {
|
||||||
// ID Identifier of the deleted dashboard.
|
// UID Identifier of the deleted dashboard.
|
||||||
// required: true
|
// required: true
|
||||||
// example: 65
|
// example: 65
|
||||||
ID int64 `json:"id"`
|
UID string `json:"uid"`
|
||||||
|
|
||||||
// Title Title of the deleted dashboard.
|
// Title Title of the deleted dashboard.
|
||||||
// required: true
|
// required: true
|
||||||
@ -1231,3 +1366,14 @@ type DashboardVersionResponse struct {
|
|||||||
// in: body
|
// in: body
|
||||||
Body *dashver.DashboardVersionMeta `json:"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")
|
page := c.QueryInt64("page")
|
||||||
dashboardType := c.Query("type")
|
dashboardType := c.Query("type")
|
||||||
sort := c.Query("sort")
|
sort := c.Query("sort")
|
||||||
|
deleted := c.Query("deleted")
|
||||||
permission := dashboardaccess.PERMISSION_VIEW
|
permission := dashboardaccess.PERMISSION_VIEW
|
||||||
|
|
||||||
if limit > 5000 {
|
if limit > 5000 {
|
||||||
@ -77,6 +78,7 @@ func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response {
|
|||||||
Limit: limit,
|
Limit: limit,
|
||||||
Page: page,
|
Page: page,
|
||||||
IsStarred: starred == "true",
|
IsStarred: starred == "true",
|
||||||
|
IsDeleted: deleted == "true",
|
||||||
OrgId: c.SignedInUser.GetOrgID(),
|
OrgId: c.SignedInUser.GetOrgID(),
|
||||||
DashboardIds: dbIDs,
|
DashboardIds: dbIDs,
|
||||||
DashboardUIDs: dbUIDs,
|
DashboardUIDs: dbUIDs,
|
||||||
@ -190,6 +192,10 @@ type SearchParams struct {
|
|||||||
// default: alpha-asc
|
// default: alpha-asc
|
||||||
// Enum: alpha-asc,alpha-desc
|
// Enum: alpha-asc,alpha-desc
|
||||||
Sort string `json:"sort"`
|
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
|
// swagger:response searchResponse
|
||||||
|
@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
"github.com/grafana/grafana/pkg/services/annotations"
|
"github.com/grafana/grafana/pkg/services/annotations"
|
||||||
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||||
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
|
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||||
@ -26,27 +27,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/setting"
|
"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 {
|
type CleanUpService struct {
|
||||||
log log.Logger
|
log log.Logger
|
||||||
tracer tracing.Tracer
|
tracer tracing.Tracer
|
||||||
@ -60,6 +40,29 @@ type CleanUpService struct {
|
|||||||
deleteExpiredImageService *image.DeleteExpiredService
|
deleteExpiredImageService *image.DeleteExpiredService
|
||||||
tempUserService tempuser.Service
|
tempUserService tempuser.Service
|
||||||
annotationCleaner annotations.Cleaner
|
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 {
|
type cleanUpJob struct {
|
||||||
@ -103,6 +106,7 @@ func (srv *CleanUpService) clean(ctx context.Context) {
|
|||||||
{"delete stale short URLs", srv.deleteStaleShortURLs},
|
{"delete stale short URLs", srv.deleteStaleShortURLs},
|
||||||
{"delete stale query history", srv.deleteStaleQueryHistory},
|
{"delete stale query history", srv.deleteStaleQueryHistory},
|
||||||
{"expire old email verifications", srv.expireOldVerifications},
|
{"expire old email verifications", srv.expireOldVerifications},
|
||||||
|
{"cleanup trash dashboards", srv.cleanUpTrashDashboards},
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := srv.log.FromContext(ctx)
|
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)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"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)
|
CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) (int64, error)
|
||||||
GetDashboardsSharedWithUser(ctx context.Context, user identity.Requester) ([]*Dashboard, error)
|
GetDashboardsSharedWithUser(ctx context.Context, user identity.Requester) ([]*Dashboard, error)
|
||||||
GetAllDashboards(ctx context.Context) ([]*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.
|
// PluginService is a service for operating on plugin dashboards.
|
||||||
@ -79,4 +84,9 @@ type Store interface {
|
|||||||
DeleteDashboardsInFolders(ctx context.Context, request *DeleteDashboardsInFolderRequest) error
|
DeleteDashboardsInFolders(ctx context.Context, request *DeleteDashboardsInFolderRequest) error
|
||||||
|
|
||||||
GetAllDashboards(ctx context.Context) ([]*Dashboard, 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
|
package dashboards
|
||||||
|
|
||||||
@ -46,6 +46,34 @@ func (_m *FakeDashboardService) BuildSaveDashboardCommand(ctx context.Context, d
|
|||||||
return r0, r1
|
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
|
// 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) {
|
func (_m *FakeDashboardService) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) (int64, error) {
|
||||||
ret := _m.Called(ctx, orgID, folderUIDs, user)
|
ret := _m.Called(ctx, orgID, folderUIDs, user)
|
||||||
@ -302,6 +330,36 @@ func (_m *FakeDashboardService) GetDashboardsSharedWithUser(ctx context.Context,
|
|||||||
return r0, r1
|
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
|
// ImportDashboard provides a mock function with given fields: ctx, dto
|
||||||
func (_m *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*Dashboard, error) {
|
func (_m *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*Dashboard, error) {
|
||||||
ret := _m.Called(ctx, dto)
|
ret := _m.Called(ctx, dto)
|
||||||
@ -332,6 +390,24 @@ func (_m *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDa
|
|||||||
return r0, r1
|
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
|
// SaveDashboard provides a mock function with given fields: ctx, dto, allowUiUpdate
|
||||||
func (_m *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*Dashboard, error) {
|
func (_m *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*Dashboard, error) {
|
||||||
ret := _m.Called(ctx, dto, allowUiUpdate)
|
ret := _m.Called(ctx, dto, allowUiUpdate)
|
||||||
@ -392,6 +468,24 @@ func (_m *FakeDashboardService) SearchDashboards(ctx context.Context, query *Fin
|
|||||||
return r0, r1
|
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.
|
// 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.
|
// The first argument is typically a *testing.T value.
|
||||||
func NewFakeDashboardService(t interface {
|
func NewFakeDashboardService(t interface {
|
||||||
|
@ -7,8 +7,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"xorm.io/xorm"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/db"
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||||
@ -16,6 +14,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
|
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"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/quota"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||||
@ -513,6 +512,67 @@ func (d *dashboardStore) GetDashboardsByPluginID(ctx context.Context, query *das
|
|||||||
}
|
}
|
||||||
return dashboards, nil
|
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 {
|
func (d *dashboardStore) DeleteDashboard(ctx context.Context, cmd *dashboards.DeleteDashboardCommand) error {
|
||||||
return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) 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 {
|
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 {
|
if err := d.deleteChildrenDashboardAssociations(sess, &dashboard); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove all access control permission with folder scope
|
// 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()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !has {
|
} 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 {
|
if len(query.DashboardIDs) == 0 && len(query.DashboardUIDs) == 0 {
|
||||||
return star.ErrCommandValidationFailed
|
return star.ErrCommandValidationFailed
|
||||||
}
|
}
|
||||||
var session *xorm.Session
|
|
||||||
|
// remove soft deleted dashboards from the response
|
||||||
|
sess.Where("deleted IS NULL")
|
||||||
|
|
||||||
if len(query.DashboardIDs) > 0 {
|
if len(query.DashboardIDs) > 0 {
|
||||||
session = sess.In("id", query.DashboardIDs)
|
sess.In("id", query.DashboardIDs)
|
||||||
} else {
|
} else {
|
||||||
session = sess.In("uid", query.DashboardUIDs)
|
sess.In("uid", query.DashboardUIDs)
|
||||||
}
|
}
|
||||||
if query.OrgID > 0 {
|
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
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
var res []dashboards.DashboardSearchProjection
|
||||||
sb := &searchstore.Builder{Dialect: d.store.GetDialect(), Filters: filters, Features: d.features}
|
sb := &searchstore.Builder{Dialect: d.store.GetDialect(), Filters: filters, Features: d.features}
|
||||||
|
|
||||||
@ -871,7 +939,7 @@ func (d *dashboardStore) CountDashboardsInFolders(
|
|||||||
args = append(args, folderUID)
|
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))
|
args = append(args, req.OrgID, d.store.GetDialect().BooleanStr(false))
|
||||||
sql := s.String()
|
sql := s.String()
|
||||||
_, err := sess.SQL(sql, args...).Get(&count)
|
_, err := sess.SQL(sql, args...).Get(&count)
|
||||||
@ -919,6 +987,18 @@ func (d *dashboardStore) GetAllDashboards(ctx context.Context) ([]*dashboards.Da
|
|||||||
return dashboards, nil
|
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) {
|
func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) {
|
||||||
limits := "a.Map{}
|
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 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")
|
_ = insertTestDashboard(t, dashboardStore, "delete me 2", 1, folder.ID, folder.UID, false, "delete this 2")
|
||||||
|
|
||||||
err := dashboardStore.DeleteDashboardsInFolders(
|
err := dashboardStore.SoftDeleteDashboardsInFolders(context.Background(), folder.OrgID, []string{folder.UID})
|
||||||
context.Background(),
|
|
||||||
&dashboards.DeleteDashboardsInFolderRequest{
|
|
||||||
FolderUIDs: []string{folder.UID},
|
|
||||||
OrgID: 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
count, err := dashboardStore.CountDashboardsInFolders(context.Background(), &dashboards.CountDashboardsInFolderRequest{FolderUIDs: []string{folder.UID}, OrgID: 1})
|
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) {
|
func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
t.Skip("skipping integration test")
|
t.Skip("skipping integration test")
|
||||||
|
@ -116,6 +116,11 @@ var (
|
|||||||
StatusCode: 404,
|
StatusCode: 404,
|
||||||
Status: "not-found",
|
Status: "not-found",
|
||||||
}
|
}
|
||||||
|
ErrFolderRestoreNotFound = DashboardErr{
|
||||||
|
Reason: "Restoring folder not found",
|
||||||
|
StatusCode: 400,
|
||||||
|
Status: "bad-request",
|
||||||
|
}
|
||||||
|
|
||||||
ErrFolderNotFound = errors.New("folder not found")
|
ErrFolderNotFound = errors.New("folder not found")
|
||||||
ErrFolderVersionMismatch = errors.New("the folder has been changed by someone else")
|
ErrFolderVersionMismatch = errors.New("the folder has been changed by someone else")
|
||||||
|
@ -36,6 +36,7 @@ type Dashboard struct {
|
|||||||
|
|
||||||
Created time.Time
|
Created time.Time
|
||||||
Updated time.Time
|
Updated time.Time
|
||||||
|
Deleted time.Time
|
||||||
|
|
||||||
UpdatedBy int64
|
UpdatedBy int64
|
||||||
CreatedBy int64
|
CreatedBy int64
|
||||||
@ -209,6 +210,10 @@ type SaveDashboardCommand struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RestoreDeletedDashboardCommand struct {
|
||||||
|
FolderUID string `json:"folderUid" xorm:"folder_uid"`
|
||||||
|
}
|
||||||
|
|
||||||
type DashboardProvisioning struct {
|
type DashboardProvisioning struct {
|
||||||
ID int64 `xorm:"pk autoincr 'id'"`
|
ID int64 `xorm:"pk autoincr 'id'"`
|
||||||
DashboardID int64 `xorm:"dashboard_id"`
|
DashboardID int64 `xorm:"dashboard_id"`
|
||||||
@ -219,10 +224,11 @@ type DashboardProvisioning struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DeleteDashboardCommand struct {
|
type DeleteDashboardCommand struct {
|
||||||
ID int64
|
ID int64
|
||||||
UID string
|
UID string
|
||||||
OrgID int64
|
OrgID int64
|
||||||
ForceDeleteFolderRules bool
|
ForceDeleteFolderRules bool
|
||||||
|
SkipSoftDeletedDashboards bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeleteOrphanedProvisionedDashboardsCommand struct {
|
type DeleteOrphanedProvisionedDashboardsCommand struct {
|
||||||
@ -307,6 +313,7 @@ type DashboardSearchProjection struct {
|
|||||||
FolderSlug string
|
FolderSlug string
|
||||||
FolderTitle string
|
FolderTitle string
|
||||||
SortMeta int64
|
SortMeta int64
|
||||||
|
Deleted *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -414,6 +421,7 @@ type FindPersistedDashboardsQuery struct {
|
|||||||
Page int64
|
Page int64
|
||||||
Permission dashboardaccess.PermissionType
|
Permission dashboardaccess.PermissionType
|
||||||
Sort model.SortOption
|
Sort model.SortOption
|
||||||
|
IsDeleted bool
|
||||||
|
|
||||||
Filters []any
|
Filters []any
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -41,6 +42,8 @@ var (
|
|||||||
_ dashboards.DashboardService = (*DashboardServiceImpl)(nil)
|
_ dashboards.DashboardService = (*DashboardServiceImpl)(nil)
|
||||||
_ dashboards.DashboardProvisioningService = (*DashboardServiceImpl)(nil)
|
_ dashboards.DashboardProvisioningService = (*DashboardServiceImpl)(nil)
|
||||||
_ dashboards.PluginService = (*DashboardServiceImpl)(nil)
|
_ dashboards.PluginService = (*DashboardServiceImpl)(nil)
|
||||||
|
|
||||||
|
daysInTrash = 24 * 30 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
type DashboardServiceImpl struct {
|
type DashboardServiceImpl struct {
|
||||||
@ -367,6 +370,61 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar
|
|||||||
|
|
||||||
return dash, nil
|
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
|
// 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.
|
// 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 {
|
if len(item.Term) > 0 {
|
||||||
hit.Tags = append(hit.Tags, item.Term)
|
hit.Tags = append(hit.Tags, item.Term)
|
||||||
}
|
}
|
||||||
|
if item.Deleted != nil {
|
||||||
|
hit.RemainingTrashAtAge = util.RemainingDaysUntil((*item.Deleted).Add(daysInTrash))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return hitList
|
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 {
|
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})
|
return dr.dashboardStore.DeleteDashboardsInFolders(ctx, &dashboards.DeleteDashboardsInFolderRequest{FolderUIDs: folderUIDs, OrgID: orgID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dr *DashboardServiceImpl) Kind() string { return entity.StandardKindDashboard }
|
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/appcontext"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"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"
|
||||||
"github.com/grafana/grafana/pkg/services/folder/foldertest"
|
"github.com/grafana/grafana/pkg/services/folder/foldertest"
|
||||||
"github.com/grafana/grafana/pkg/services/guardian"
|
"github.com/grafana/grafana/pkg/services/guardian"
|
||||||
@ -30,6 +31,7 @@ func TestDashboardService(t *testing.T) {
|
|||||||
log: log.New("test.logger"),
|
log: log.New("test.logger"),
|
||||||
dashboardStore: &fakeStore,
|
dashboardStore: &fakeStore,
|
||||||
folderService: folderSvc,
|
folderService: folderSvc,
|
||||||
|
features: featuremgmt.WithFeatures(),
|
||||||
}
|
}
|
||||||
|
|
||||||
origNewDashboardGuardian := guardian.New
|
origNewDashboardGuardian := guardian.New
|
||||||
@ -212,5 +214,12 @@ func TestDashboardService(t *testing.T) {
|
|||||||
err := service.DeleteInFolders(context.Background(), 1, []string{"uid"}, nil)
|
err := service.DeleteInFolders(context.Background(), 1, []string{"uid"}, nil)
|
||||||
require.NoError(t, err)
|
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
|
package dashboards
|
||||||
|
|
||||||
import (
|
import (
|
||||||
context "context"
|
context "context"
|
||||||
|
|
||||||
quota "github.com/grafana/grafana/pkg/services/quota"
|
folder "github.com/grafana/grafana/pkg/services/folder"
|
||||||
mock "github.com/stretchr/testify/mock"
|
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
|
// FakeDashboardStore is an autogenerated mock type for the Store type
|
||||||
@ -426,6 +430,84 @@ func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(ctx context.Conte
|
|||||||
return r0, r1
|
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
|
// SaveDashboard provides a mock function with given fields: ctx, cmd
|
||||||
func (_m *FakeDashboardStore) SaveDashboard(ctx context.Context, cmd SaveDashboardCommand) (*Dashboard, error) {
|
func (_m *FakeDashboardStore) SaveDashboard(ctx context.Context, cmd SaveDashboardCommand) (*Dashboard, error) {
|
||||||
ret := _m.Called(ctx, cmd)
|
ret := _m.Called(ctx, cmd)
|
||||||
@ -486,6 +568,42 @@ func (_m *FakeDashboardStore) SaveProvisionedDashboard(ctx context.Context, cmd
|
|||||||
return r0, r1
|
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
|
// UnprovisionDashboard provides a mock function with given fields: ctx, id
|
||||||
func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error {
|
func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error {
|
||||||
ret := _m.Called(ctx, id)
|
ret := _m.Called(ctx, id)
|
||||||
|
@ -1268,6 +1268,14 @@ var (
|
|||||||
Owner: grafanaFrontendPlatformSquad,
|
Owner: grafanaFrontendPlatformSquad,
|
||||||
FrontendOnly: false,
|
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
|
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
|
||||||
newDashboardSharingComponent,experimental,@grafana/sharing-squad,false,false,true
|
newDashboardSharingComponent,experimental,@grafana/sharing-squad,false,false,true
|
||||||
notificationBanner,experimental,@grafana/grafana-frontend-platform,false,false,false
|
notificationBanner,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||||
|
dashboardRestore,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||||
|
|
@ -686,4 +686,8 @@ const (
|
|||||||
// FlagNotificationBanner
|
// FlagNotificationBanner
|
||||||
// Enables the notification banner UI and API
|
// Enables the notification banner UI and API
|
||||||
FlagNotificationBanner = "notificationBanner"
|
FlagNotificationBanner = "notificationBanner"
|
||||||
|
|
||||||
|
// FlagDashboardRestore
|
||||||
|
// Enables deleted dashboard restore feature
|
||||||
|
FlagDashboardRestore = "dashboardRestore"
|
||||||
)
|
)
|
||||||
|
@ -2206,6 +2206,20 @@
|
|||||||
"stage": "experimental",
|
"stage": "experimental",
|
||||||
"codeowner": "@grafana/search-and-storage"
|
"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 {
|
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
|
// TODO use bulk delete
|
||||||
for _, folderUID := range folderUIDs {
|
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 {
|
if err := s.dashboardStore.DeleteDashboard(ctx, &deleteCmd); err != nil {
|
||||||
return toFolderError(err)
|
return toFolderError(err)
|
||||||
}
|
}
|
||||||
|
@ -280,7 +280,68 @@ func TestIntegrationFolderService(t *testing.T) {
|
|||||||
require.Equal(t, expectedForceDeleteRules, actualCmd.ForceDeleteFolderRules)
|
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() {
|
t.Cleanup(func() {
|
||||||
|
service.features = featuremgmt.WithFeatures()
|
||||||
guardian.New = origNewGuardian
|
guardian.New = origNewGuardian
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -62,22 +62,22 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Hit struct {
|
type Hit struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UID string `json:"uid"`
|
UID string `json:"uid"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
URI string `json:"uri"`
|
URI string `json:"uri"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Type HitType `json:"type"`
|
Type HitType `json:"type"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
IsStarred bool `json:"isStarred"`
|
IsStarred bool `json:"isStarred"`
|
||||||
// Deprecated: use FolderUID instead
|
FolderID int64 `json:"folderId,omitempty"` // Deprecated: use FolderUID instead
|
||||||
FolderID int64 `json:"folderId,omitempty"`
|
FolderUID string `json:"folderUid,omitempty"`
|
||||||
FolderUID string `json:"folderUid,omitempty"`
|
FolderTitle string `json:"folderTitle,omitempty"`
|
||||||
FolderTitle string `json:"folderTitle,omitempty"`
|
FolderURL string `json:"folderUrl,omitempty"`
|
||||||
FolderURL string `json:"folderUrl,omitempty"`
|
SortMeta int64 `json:"sortMeta"`
|
||||||
SortMeta int64 `json:"sortMeta"`
|
SortMetaName string `json:"sortMetaName,omitempty"`
|
||||||
SortMetaName string `json:"sortMetaName,omitempty"`
|
RemainingTrashAtAge string `json:"remainingTrashAtAge,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HitList []*Hit
|
type HitList []*Hit
|
||||||
|
@ -36,6 +36,7 @@ type Query struct {
|
|||||||
Limit int64
|
Limit int64
|
||||||
Page int64
|
Page int64
|
||||||
IsStarred bool
|
IsStarred bool
|
||||||
|
IsDeleted bool
|
||||||
Type string
|
Type string
|
||||||
DashboardUIDs []string
|
DashboardUIDs []string
|
||||||
DashboardIds []int64
|
DashboardIds []int64
|
||||||
@ -93,6 +94,7 @@ func (s *SearchService) SearchHandler(ctx context.Context, query *Query) (model.
|
|||||||
Limit: query.Limit,
|
Limit: query.Limit,
|
||||||
Page: query.Page,
|
Page: query.Page,
|
||||||
Permission: query.Permission,
|
Permission: query.Permission,
|
||||||
|
IsDeleted: query.IsDeleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
if sortOpt, exists := s.sortOptions[query.Sort]; exists {
|
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{
|
mg.AddMigration("Add isPublic for dashboard", NewAddColumnMigration(dashboardV2, &Column{
|
||||||
Name: "is_public", Type: DB_Bool, Nullable: false, Default: "0",
|
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_tag.term,
|
||||||
dashboard.is_folder,
|
dashboard.is_folder,
|
||||||
dashboard.folder_id,
|
dashboard.folder_id,
|
||||||
|
dashboard.deleted,
|
||||||
folder.uid AS folder_uid,
|
folder.uid AS folder_uid,
|
||||||
`)
|
`)
|
||||||
if b.Features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) {
|
if b.Features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) {
|
||||||
|
@ -198,3 +198,15 @@ var _ model.FilterWhere = &FolderWithAlertsFilter{}
|
|||||||
func (f FolderWithAlertsFilter) Where() (string, []any) {
|
func (f FolderWithAlertsFilter) Where() (string, []any) {
|
||||||
return "EXISTS (SELECT 1 FROM alert_rule WHERE alert_rule.namespace_uid = dashboard.uid)", nil
|
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"
|
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.
|
// ToCamelCase changes kebab case, snake case or mixed strings to camel case. See unit test for examples.
|
||||||
func ToCamelCase(str string) string {
|
func ToCamelCase(str string) string {
|
||||||
var finalParts []string
|
var finalParts []string
|
||||||
|
@ -4598,7 +4598,6 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"folderId": {
|
"folderId": {
|
||||||
"description": "Deprecated: use FolderUID instead",
|
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
},
|
},
|
||||||
@ -4618,6 +4617,9 @@
|
|||||||
"isStarred": {
|
"isStarred": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"remainingTrashAtAge": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"slug": {
|
"slug": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@ -6387,6 +6389,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"RestoreDeletedDashboardCommand": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"folderUid": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RevokeAuthTokenCmd": {
|
"RevokeAuthTokenCmd": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -8401,17 +8411,11 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"uid",
|
||||||
"title",
|
"title",
|
||||||
"message"
|
"message"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
|
||||||
"description": "ID Identifier of the deleted dashboard.",
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int64",
|
|
||||||
"example": 65
|
|
||||||
},
|
|
||||||
"message": {
|
"message": {
|
||||||
"description": "Message Message of the deleted dashboard.",
|
"description": "Message Message of the deleted dashboard.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -8421,6 +8425,11 @@
|
|||||||
"description": "Title Title of the deleted dashboard.",
|
"description": "Title Title of the deleted dashboard.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "My Dashboard"
|
"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": {
|
"/dashboards/uid/{uid}/versions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -8089,6 +8167,12 @@
|
|||||||
"description": "Sort method; for listing all the possible sort methods use the search sorting endpoint.",
|
"description": "Sort method; for listing all the possible sort methods use the search sorting endpoint.",
|
||||||
"name": "sort",
|
"name": "sort",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Flag indicating if only soft deleted Dashboards should be returned",
|
||||||
|
"name": "deleted",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -15480,7 +15564,6 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"folderId": {
|
"folderId": {
|
||||||
"description": "Deprecated: use FolderUID instead",
|
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
},
|
},
|
||||||
@ -15500,6 +15583,9 @@
|
|||||||
"isStarred": {
|
"isStarred": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"remainingTrashAtAge": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"slug": {
|
"slug": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@ -18521,6 +18607,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"RestoreDeletedDashboardCommand": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"folderUid": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RevokeAuthTokenCmd": {
|
"RevokeAuthTokenCmd": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -22144,17 +22238,11 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"uid",
|
||||||
"title",
|
"title",
|
||||||
"message"
|
"message"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
|
||||||
"description": "ID Identifier of the deleted dashboard.",
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int64",
|
|
||||||
"example": 65
|
|
||||||
},
|
|
||||||
"message": {
|
"message": {
|
||||||
"description": "Message Message of the deleted dashboard.",
|
"description": "Message Message of the deleted dashboard.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -22164,6 +22252,11 @@
|
|||||||
"description": "Title Title of the deleted dashboard.",
|
"description": "Title Title of the deleted dashboard.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "My Dashboard"
|
"example": "My Dashboard"
|
||||||
|
},
|
||||||
|
"uid": {
|
||||||
|
"description": "UID Identifier of the deleted dashboard.",
|
||||||
|
"type": "string",
|
||||||
|
"example": "65"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -488,12 +488,6 @@
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
|
||||||
"description": "ID Identifier of the deleted dashboard.",
|
|
||||||
"example": 65,
|
|
||||||
"format": "int64",
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"message": {
|
"message": {
|
||||||
"description": "Message Message of the deleted dashboard.",
|
"description": "Message Message of the deleted dashboard.",
|
||||||
"example": "Dashboard My Dashboard deleted",
|
"example": "Dashboard My Dashboard deleted",
|
||||||
@ -503,10 +497,15 @@
|
|||||||
"description": "Title Title of the deleted dashboard.",
|
"description": "Title Title of the deleted dashboard.",
|
||||||
"example": "My Dashboard",
|
"example": "My Dashboard",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"uid": {
|
||||||
|
"description": "UID Identifier of the deleted dashboard.",
|
||||||
|
"example": "65",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"uid",
|
||||||
"title",
|
"title",
|
||||||
"message"
|
"message"
|
||||||
],
|
],
|
||||||
@ -6126,7 +6125,6 @@
|
|||||||
"Hit": {
|
"Hit": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"folderId": {
|
"folderId": {
|
||||||
"description": "Deprecated: use FolderUID instead",
|
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@ -6146,6 +6144,9 @@
|
|||||||
"isStarred": {
|
"isStarred": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"remainingTrashAtAge": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"slug": {
|
"slug": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@ -9168,6 +9169,14 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"RestoreDeletedDashboardCommand": {
|
||||||
|
"properties": {
|
||||||
|
"folderUid": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"RevokeAuthTokenCmd": {
|
"RevokeAuthTokenCmd": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"authTokenId": {
|
"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": {
|
"/dashboards/uid/{uid}/versions": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getDashboardVersionsByUID",
|
"operationId": "getDashboardVersionsByUID",
|
||||||
@ -21163,6 +21257,14 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Flag indicating if only soft deleted Dashboards should be returned",
|
||||||
|
"in": "query",
|
||||||
|
"name": "deleted",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
Loading…
Reference in New Issue
Block a user