diff --git a/qa/tasks/mgr/dashboard/test_rbd.py b/qa/tasks/mgr/dashboard/test_rbd.py
index c240d0609eaa5..db637ca625479 100644
--- a/qa/tasks/mgr/dashboard/test_rbd.py
+++ b/qa/tasks/mgr/dashboard/test_rbd.py
@@ -3,6 +3,8 @@
from __future__ import absolute_import
+import time
+
from .helper import DashboardTestCase, JObj, JLeaf, JList
@@ -136,6 +138,31 @@ def tearDownClass(cls):
cls._ceph_cmd(['osd', 'pool', 'delete', 'rbd_data', 'rbd_data',
'--yes-i-really-really-mean-it'])
+ @classmethod
+ def create_image_in_trash(cls, pool, name, delay=0, **kwargs):
+ cls.create_image(pool, name, 10240)
+ img = cls._get('/api/block/image/{}/{}'.format(pool, name))
+
+ cls._task_post("/api/block/image/{}/{}/move_trash".format(pool, name),
+ {'delay': delay})
+
+ return img['id']
+
+ @classmethod
+ def remove_trash(cls, pool, image_id, image_name, force=False):
+ return cls._task_delete('/api/block/image/trash/{}/{}/?image_name={}&force={}'.format('rbd', image_id, image_name, force))
+
+ @classmethod
+ def get_trash(cls, pool, image_id):
+ trash = cls._get('/api/block/image/trash/?pool_name={}'.format(pool))
+ if isinstance(trash, list):
+ for pool in trash:
+ for image in pool['value']:
+ if image['id'] == image_id:
+ return image
+
+ return None
+
def _validate_image(self, img, **kwargs):
"""
Example of an RBD image json:
@@ -599,3 +626,83 @@ def test_image_with_special_name(self):
'object-map'])
self.remove_image('rbd', rbd_name_encoded)
+
+ def test_move_image_to_trash(self):
+ id = self.create_image_in_trash('rbd', 'test_rbd')
+ self.assertStatus(200)
+
+ self._get('/api/block/image/rbd/test_rbd')
+ self.assertStatus(404)
+
+ time.sleep(1)
+
+ image = self.get_trash('rbd', id)
+ self.assertIsNotNone(image)
+
+ self.remove_trash('rbd', id, 'test_rbd')
+
+ def test_list_trash(self):
+ id = self.create_image_in_trash('rbd', 'test_rbd', 0)
+ data = self._get('/api/block/image/trash/?pool_name={}'.format('rbd'))
+ self.assertStatus(200)
+ self.assertIsInstance(data, list)
+ self.assertIsNotNone(data)
+
+ self.remove_trash('rbd', id, 'test_rbd')
+ self.assertStatus(204)
+
+ def test_restore_trash(self):
+ id = self.create_image_in_trash('rbd', 'test_rbd')
+
+ self._task_post('/api/block/image/trash/{}/{}/restore'.format('rbd', id), {'new_image_name': 'test_rbd'})
+
+ self._get('/api/block/image/rbd/test_rbd')
+ self.assertStatus(200)
+
+ image = self.get_trash('rbd', id)
+ self.assertIsNone(image)
+
+ self.remove_image('rbd', 'test_rbd')
+
+ def test_remove_expired_trash(self):
+ id = self.create_image_in_trash('rbd', 'test_rbd', 0)
+ self.remove_trash('rbd', id, 'test_rbd', False)
+ self.assertStatus(204)
+
+ image = self.get_trash('rbd', id)
+ self.assertIsNone(image)
+
+ def test_remove_not_expired_trash(self):
+ id = self.create_image_in_trash('rbd', 'test_rbd', 9999)
+ self.remove_trash('rbd', id, 'test_rbd', False)
+ self.assertStatus(400)
+
+ image = self.get_trash('rbd', id)
+ self.assertIsNotNone(image)
+
+ self.remove_trash('rbd', id, 'test_rbd', True)
+
+ def test_remove_not_expired_trash_with_force(self):
+ id = self.create_image_in_trash('rbd', 'test_rbd', 9999)
+ self.remove_trash('rbd', id, 'test_rbd', True)
+ self.assertStatus(204)
+
+ image = self.get_trash('rbd', id)
+ self.assertIsNone(image)
+
+ def test_purge_trash(self):
+ id_expired = self.create_image_in_trash('rbd', 'test_rbd_expired', 0)
+ id_not_expired = self.create_image_in_trash('rbd', 'test_rbd', 9999)
+
+ time.sleep(1)
+
+ self._task_post('/api/block/image/trash/purge?pool_name={}'.format('rbd'))
+ self.assertStatus(200)
+
+ time.sleep(1)
+
+ trash_not_expired = self.get_trash('rbd', id_not_expired)
+ self.assertIsNotNone(trash_not_expired)
+
+ trash_expired = self.get_trash('rbd', id_expired)
+ self.assertIsNone(trash_expired)
diff --git a/src/pybind/mgr/dashboard/controllers/rbd.py b/src/pybind/mgr/dashboard/controllers/rbd.py
index d9b217fd6f75b..a8f4d452a45ec 100644
--- a/src/pybind/mgr/dashboard/controllers/rbd.py
+++ b/src/pybind/mgr/dashboard/controllers/rbd.py
@@ -5,6 +5,7 @@
import math
from functools import partial
+from datetime import datetime
import cherrypy
import six
@@ -15,7 +16,7 @@
from .. import mgr
from ..security import Scope
from ..services.ceph_service import CephService
-from ..tools import ViewCache
+from ..tools import ViewCache, str_to_bool
from ..services.exception import handle_rados_error, handle_rbd_error, \
serialize_dashboard_exception
@@ -374,6 +375,16 @@ def default_features(self):
rbd_default_features = mgr.get('config')['rbd_default_features']
return _format_bitmask(int(rbd_default_features))
+ @RbdTask('trash/move', ['{pool_name}', '{image_name}'], 2.0)
+ @RESTController.Resource('POST')
+ def move_trash(self, pool_name, image_name, delay=0):
+ """Move an image to the trash.
+ Images, even ones actively in-use by clones,
+ can be moved to the trash and deleted at a later time.
+ """
+ rbd_inst = rbd.RBD()
+ return _rbd_call(pool_name, rbd_inst.trash_move, image_name, delay)
+
@ApiController('/block/image/{pool_name}/{image_name}/snap', Scope.RBD_IMAGE)
class RbdSnapshot(RESTController):
@@ -453,3 +464,68 @@ def _clone(ioctx):
return _rbd_call(child_pool_name, _clone)
return _rbd_call(pool_name, _parent_clone)
+
+
+@ApiController('/block/image/trash')
+class RbdTrash(RESTController):
+ RESOURCE_ID = "pool_name/image_id"
+ rbd_inst = rbd.RBD()
+
+ @ViewCache()
+ def _trash_pool_list(self, pool_name):
+ with mgr.rados.open_ioctx(pool_name) as ioctx:
+ images = self.rbd_inst.trash_list(ioctx)
+ result = []
+ for trash in images:
+ trash['pool_name'] = pool_name
+ trash['deletion_time'] = "{}Z".format(trash['deletion_time'].isoformat())
+ trash['deferment_end_time'] = "{}Z".format(trash['deferment_end_time'].isoformat())
+ result.append(trash)
+ return result
+
+ def _trash_list(self, pool_name=None):
+ if pool_name:
+ pools = [pool_name]
+ else:
+ pools = [p['pool_name'] for p in CephService.get_pool_list('rbd')]
+
+ result = []
+ for pool in pools:
+ # pylint: disable=unbalanced-tuple-unpacking
+ status, value = self._trash_pool_list(pool)
+ result.append({'status': status, 'value': value, 'pool_name': pool})
+ return result
+
+ @handle_rbd_error()
+ @handle_rados_error('pool')
+ def list(self, pool_name=None):
+ """List all entries from trash."""
+ return self._trash_list(pool_name)
+
+ @handle_rbd_error()
+ @handle_rados_error('pool')
+ @RbdTask('trash/purge', ['{pool_name}'], 2.0)
+ @RESTController.Collection('POST', query_params=['pool_name'])
+ def purge(self, pool_name=None):
+ """Remove all expired images from trash."""
+ now = "{}Z".format(datetime.now().isoformat())
+ pools = self._trash_list(pool_name)
+
+ for pool in pools:
+ for image in pool['value']:
+ if image['deferment_end_time'] < now:
+ _rbd_call(pool['pool_name'], self.rbd_inst.trash_remove, image['id'], 0)
+
+ @RbdTask('trash/restore', ['{pool_name}', '{image_id}', '{new_image_name}'], 2.0)
+ @RESTController.Resource('POST')
+ def restore(self, pool_name, image_id, new_image_name):
+ """Restore an image from trash."""
+ return _rbd_call(pool_name, self.rbd_inst.trash_restore, image_id, new_image_name)
+
+ @RbdTask('trash/remove', ['{pool_name}', '{image_id}', '{image_name}'], 2.0)
+ def delete(self, pool_name, image_id, image_name, force=False):
+ """Delete an image from trash.
+ If image deferment time has not expired you can not removed it unless use force.
+ But an actively in-use by clones or has snapshots can not be removed.
+ """
+ return _rbd_call(pool_name, self.rbd_inst.trash_remove, image_id, int(str_to_bool(force)))
diff --git a/src/pybind/mgr/dashboard/frontend/angular.json b/src/pybind/mgr/dashboard/frontend/angular.json
index 35d23369aa4d2..3b30176bfec96 100644
--- a/src/pybind/mgr/dashboard/frontend/angular.json
+++ b/src/pybind/mgr/dashboard/frontend/angular.json
@@ -25,6 +25,7 @@
"node_modules/ng2-toastr/bundles/ng2-toastr.min.css",
"node_modules/fork-awesome/css/fork-awesome.css",
"node_modules/awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css",
+ "node_modules/ngx-bootstrap/datepicker/bs-datepicker.css",
"src/styles.scss"
],
"scripts": [
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
index 5de3f59300082..efe4ed58db89d 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
@@ -4,7 +4,7 @@ import { ActivatedRouteSnapshot, RouterModule, Routes } from '@angular/router';
import { IscsiComponent } from './ceph/block/iscsi/iscsi.component';
import { MirroringComponent } from './ceph/block/mirroring/mirroring.component';
import { RbdFormComponent } from './ceph/block/rbd-form/rbd-form.component';
-import { RbdListComponent } from './ceph/block/rbd-list/rbd-list.component';
+import { RbdImagesComponent } from './ceph/block/rbd-images/rbd-images.component';
import { CephfsListComponent } from './ceph/cephfs/cephfs-list/cephfs-list.component';
import { ConfigurationComponent } from './ceph/cluster/configuration/configuration.component';
import { HostsComponent } from './ceph/cluster/hosts/hosts.component';
@@ -107,7 +107,7 @@ const routes: Routes = [
path: 'rbd',
data: { breadcrumbs: 'Images' },
children: [
- { path: '', component: RbdListComponent },
+ { path: '', component: RbdImagesComponent },
{ path: 'add', component: RbdFormComponent, data: { breadcrumbs: 'Add' } },
{ path: 'edit/:pool/:name', component: RbdFormComponent, data: { breadcrumbs: 'Edit' } },
{
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
index 78b2c4a013dc6..36606c6f30219 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
@@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { BsDropdownModule, ModalModule, TabsModule, TooltipModule } from 'ngx-bootstrap';
+import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
import { ProgressbarModule } from 'ngx-bootstrap/progressbar';
import { SharedModule } from '../../shared/shared.module';
@@ -12,12 +13,23 @@ import { MirrorHealthColorPipe } from './mirror-health-color.pipe';
import { MirroringComponent } from './mirroring/mirroring.component';
import { RbdDetailsComponent } from './rbd-details/rbd-details.component';
import { RbdFormComponent } from './rbd-form/rbd-form.component';
+import { RbdImagesComponent } from './rbd-images/rbd-images.component';
import { RbdListComponent } from './rbd-list/rbd-list.component';
import { RbdSnapshotFormComponent } from './rbd-snapshot-form/rbd-snapshot-form.component';
import { RbdSnapshotListComponent } from './rbd-snapshot-list/rbd-snapshot-list.component';
+import { RbdTrashListComponent } from './rbd-trash-list/rbd-trash-list.component';
+import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-move-modal.component';
+import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal/rbd-trash-purge-modal.component';
+import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-trash-restore-modal.component';
@NgModule({
- entryComponents: [RbdDetailsComponent, RbdSnapshotFormComponent],
+ entryComponents: [
+ RbdDetailsComponent,
+ RbdSnapshotFormComponent,
+ RbdTrashMoveModalComponent,
+ RbdTrashRestoreModalComponent,
+ RbdTrashPurgeModalComponent
+ ],
imports: [
CommonModule,
FormsModule,
@@ -25,6 +37,7 @@ import { RbdSnapshotListComponent } from './rbd-snapshot-list/rbd-snapshot-list.
TabsModule.forRoot(),
ProgressbarModule.forRoot(),
BsDropdownModule.forRoot(),
+ BsDatepickerModule.forRoot(),
TooltipModule.forRoot(),
ModalModule.forRoot(),
SharedModule,
@@ -38,7 +51,12 @@ import { RbdSnapshotListComponent } from './rbd-snapshot-list/rbd-snapshot-list.
RbdDetailsComponent,
RbdFormComponent,
RbdSnapshotListComponent,
- RbdSnapshotFormComponent
+ RbdSnapshotFormComponent,
+ RbdTrashListComponent,
+ RbdTrashMoveModalComponent,
+ RbdImagesComponent,
+ RbdTrashRestoreModalComponent,
+ RbdTrashPurgeModalComponent
]
})
export class BlockModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.html
new file mode 100644
index 0000000000000..f629bd7e78da5
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.scss
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.spec.ts
new file mode 100644
index 0000000000000..82cc3f7efbf21
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.spec.ts
@@ -0,0 +1,50 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { TabsModule, TooltipModule } from 'ngx-bootstrap';
+
+import { TaskListService } from '../../../shared/services/task-list.service';
+import { SharedModule } from '../../../shared/shared.module';
+import { RbdDetailsComponent } from '../rbd-details/rbd-details.component';
+import { RbdListComponent } from '../rbd-list/rbd-list.component';
+import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
+import { RbdTrashListComponent } from '../rbd-trash-list/rbd-trash-list.component';
+import { RbdImagesComponent } from './rbd-images.component';
+
+describe('RbdImagesComponent', () => {
+ let component: RbdImagesComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [
+ RbdDetailsComponent,
+ RbdImagesComponent,
+ RbdListComponent,
+ RbdSnapshotListComponent,
+ RbdTrashListComponent
+ ],
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ TabsModule.forRoot(),
+ ToastModule.forRoot(),
+ TooltipModule.forRoot()
+ ],
+ providers: [TaskListService]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdImagesComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.ts
new file mode 100644
index 0000000000000..78e78ff6b9f39
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.ts
@@ -0,0 +1,12 @@
+import { Component, OnInit } from '@angular/core';
+
+@Component({
+ selector: 'cd-rbd-images',
+ templateUrl: './rbd-images.component.html',
+ styleUrls: ['./rbd-images.component.scss']
+})
+export class RbdImagesComponent implements OnInit {
+ constructor() {}
+
+ ngOnInit() {}
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
index c1947a10109e1..b5d259ea99a73 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
@@ -208,7 +208,7 @@ describe('RbdListComponent', () => {
permissionHelper.testScenarios(scenario));
it('shows all actions', () => {
- expect(tableActions.tableActions.length).toBe(5);
+ expect(tableActions.tableActions.length).toBe(6);
expect(tableActions.tableActions).toEqual(component.tableActions);
});
});
@@ -221,9 +221,10 @@ describe('RbdListComponent', () => {
it(`shows 'Edit' for single selection else 'Add' as main action`, () =>
permissionHelper.testScenarios(scenario));
- it(`shows all actions except for 'Delete'`, () => {
+ it(`shows all actions except for 'Delete' and 'Move'`, () => {
expect(tableActions.tableActions.length).toBe(4);
component.tableActions.pop();
+ component.tableActions.pop();
expect(tableActions.tableActions).toEqual(component.tableActions);
});
});
@@ -238,12 +239,13 @@ describe('RbdListComponent', () => {
permissionHelper.testScenarios(scenario);
});
- it(`shows 'Add', 'Copy' and 'Delete' action`, () => {
- expect(tableActions.tableActions.length).toBe(3);
+ it(`shows 'Add', 'Copy', 'Delete' and 'Move' action`, () => {
+ expect(tableActions.tableActions.length).toBe(4);
expect(tableActions.tableActions).toEqual([
component.tableActions[0],
component.tableActions[2],
- component.tableActions[4]
+ component.tableActions[4],
+ component.tableActions[5]
]);
});
});
@@ -258,12 +260,13 @@ describe('RbdListComponent', () => {
permissionHelper.testScenarios(scenario);
});
- it(`shows 'Edit', 'Flatten' and 'Delete' action`, () => {
- expect(tableActions.tableActions.length).toBe(3);
+ it(`shows 'Edit', 'Flatten', 'Delete' and 'Move' action`, () => {
+ expect(tableActions.tableActions.length).toBe(4);
expect(tableActions.tableActions).toEqual([
component.tableActions[1],
component.tableActions[3],
- component.tableActions[4]
+ component.tableActions[4],
+ component.tableActions[5]
]);
});
});
@@ -317,9 +320,12 @@ describe('RbdListComponent', () => {
permissionHelper.testScenarios(scenario);
});
- it(`shows only 'Delete' action`, () => {
- expect(tableActions.tableActions.length).toBe(1);
- expect(tableActions.tableActions).toEqual([component.tableActions[4]]);
+ it(`shows 'Delete' and 'Move' actions`, () => {
+ expect(tableActions.tableActions.length).toBe(2);
+ expect(tableActions.tableActions).toEqual([
+ component.tableActions[4],
+ component.tableActions[5]
+ ]);
});
});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
index 62a552f2d575a..5db4a6a67e2d8 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
@@ -20,6 +20,7 @@ import { AuthStorageService } from '../../../shared/services/auth-storage.servic
import { TaskListService } from '../../../shared/services/task-list.service';
import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
import { RbdParentModel } from '../rbd-form/rbd-parent.model';
+import { RbdTrashMoveModalComponent } from '../rbd-trash-move-modal/rbd-trash-move-modal.component';
import { RbdModel } from './rbd-model';
@Component({
@@ -95,7 +96,7 @@ export class RbdListComponent implements OnInit {
};
const deleteAction: CdTableAction = {
permission: 'delete',
- icon: 'fa-trash-o',
+ icon: 'fa-times',
click: () => this.deleteRbdModal(),
name: 'Delete'
};
@@ -115,7 +116,22 @@ export class RbdListComponent implements OnInit {
click: () => this.flattenRbdModal(),
name: 'Flatten'
};
- this.tableActions = [addAction, editAction, copyAction, flattenAction, deleteAction];
+ const moveAction: CdTableAction = {
+ permission: 'delete',
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSingleSelection || selection.first().cdExecuting,
+ icon: 'fa-trash-o',
+ click: () => this.trashRbdModal(),
+ name: 'Move to Trash'
+ };
+ this.tableActions = [
+ addAction,
+ editAction,
+ copyAction,
+ flattenAction,
+ deleteAction,
+ moveAction
+ ];
}
ngOnInit() {
@@ -228,7 +244,8 @@ export class RbdListComponent implements OnInit {
'rbd/create',
'rbd/delete',
'rbd/edit',
- 'rbd/flatten'
+ 'rbd/flatten',
+ 'rbd/trash/move'
].includes(task.name);
}
@@ -255,6 +272,15 @@ export class RbdListComponent implements OnInit {
});
}
+ trashRbdModal() {
+ const initialState = {
+ metaType: 'RBD',
+ poolName: this.selection.first().pool_name,
+ imageName: this.selection.first().name
+ };
+ this.modalRef = this.modalService.show(RbdTrashMoveModalComponent, { initialState });
+ }
+
flattenRbd(poolName, imageName) {
this.taskWrapper
.wrapTaskAroundCall({
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-actions.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-actions.model.ts
index 42ce1a28c95df..7b6907e24be2e 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-actions.model.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-actions.model.ts
@@ -50,7 +50,7 @@ export class RbdSnapshotActionsModel {
};
deleteSnap: CdTableAction = {
permission: 'delete',
- icon: 'fa-trash-o',
+ icon: 'fa-times',
disable: (selection: CdTableSelection) =>
selection.hasSingleSelection && !selection.first().is_protected,
name: 'Delete'
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.html
new file mode 100644
index 0000000000000..a16ece60f8bcd
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+ Purge Trash
+
+
+
+
+
+ Expired at
+
+
+ Protected until
+
+
+ {{ value | cdDate }}
+
+
+
+
+ This image is protected until {{ expiresAt | cdDate }}.
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.scss
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.spec.ts
new file mode 100644
index 0000000000000..26dbdb66d594f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.spec.ts
@@ -0,0 +1,105 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { of } from 'rxjs';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { RbdService } from '../../../shared/api/rbd.service';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { SummaryService } from '../../../shared/services/summary.service';
+import { TaskListService } from '../../../shared/services/task-list.service';
+import { SharedModule } from '../../../shared/shared.module';
+import { RbdTrashListComponent } from './rbd-trash-list.component';
+
+describe('RbdTrashListComponent', () => {
+ let component: RbdTrashListComponent;
+ let fixture: ComponentFixture;
+ let summaryService: SummaryService;
+ let rbdService: RbdService;
+
+ configureTestBed({
+ declarations: [RbdTrashListComponent],
+ imports: [SharedModule, HttpClientTestingModule, RouterTestingModule, ToastModule.forRoot()],
+ providers: [TaskListService, RbdService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTrashListComponent);
+ component = fixture.componentInstance;
+ summaryService = TestBed.get(SummaryService);
+ rbdService = TestBed.get(RbdService);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should load trash images when summary is trigged', () => {
+ spyOn(rbdService, 'listTrash').and.callThrough();
+
+ summaryService['summaryDataSource'].next({ executingTasks: null });
+ expect(rbdService.listTrash).toHaveBeenCalled();
+ });
+
+ it('should call updateSelection', () => {
+ const selection = new CdTableSelection();
+ selection.selected = ['foo'];
+ selection.update();
+
+ expect(component.selection.hasSelection).toBeFalsy();
+ component.updateSelection(selection);
+ expect(component.selection.hasSelection).toBeTruthy();
+ });
+
+ describe('handling of executing tasks', () => {
+ let images: any[];
+
+ const addImage = (id) => {
+ images.push({
+ id: id
+ });
+ };
+
+ const addTask = (name: string, image_id: string) => {
+ const task = new ExecutingTask();
+ task.name = name;
+ task.metadata = {
+ image_id: image_id
+ };
+ summaryService.addRunningTask(task);
+ };
+
+ const expectImageTasks = (image: any, executing: string) => {
+ expect(image.cdExecuting).toEqual(executing);
+ };
+
+ beforeEach(() => {
+ images = [];
+ addImage('1');
+ addImage('2');
+ component.images = images;
+ summaryService['summaryDataSource'].next({ executingTasks: [] });
+ spyOn(rbdService, 'listTrash').and.callFake(() =>
+ of([{ poool_name: 'rbd', status: 1, value: images }])
+ );
+ fixture.detectChanges();
+ });
+
+ it('should gets all images without tasks', () => {
+ expect(component.images.length).toBe(2);
+ expect(component.images.every((image) => !image.cdExecuting)).toBeTruthy();
+ });
+
+ it('should show when an existing image is being modified', () => {
+ addTask('rbd/trash/remove', '1');
+ addTask('rbd/trash/restore', '2');
+ expect(component.images.length).toBe(2);
+ expectImageTasks(component.images[0], 'Deleting');
+ expectImageTasks(component.images[1], 'Restoring');
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.ts
new file mode 100644
index 0000000000000..6a505b3f6bddb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-list/rbd-trash-list.component.ts
@@ -0,0 +1,205 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import * as _ from 'lodash';
+import * as moment from 'moment';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap';
+
+import { RbdService } from '../../../shared/api/rbd.service';
+import { DeletionModalComponent } from '../../../shared/components/deletion-modal/deletion-modal.component';
+import { TableComponent } from '../../../shared/datatable/table/table.component';
+import { CellTemplate } from '../../../shared/enum/cell-template.enum';
+import { ViewCacheStatus } from '../../../shared/enum/view-cache-status.enum';
+import { CdTableAction } from '../../../shared/models/cd-table-action';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { Permission } from '../../../shared/models/permissions';
+import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { TaskListService } from '../../../shared/services/task-list.service';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { RbdTrashPurgeModalComponent } from '../rbd-trash-purge-modal/rbd-trash-purge-modal.component';
+import { RbdTrashRestoreModalComponent } from '../rbd-trash-restore-modal/rbd-trash-restore-modal.component';
+
+@Component({
+ selector: 'cd-rbd-trash-list',
+ templateUrl: './rbd-trash-list.component.html',
+ styleUrls: ['./rbd-trash-list.component.scss'],
+ providers: [TaskListService]
+})
+export class RbdTrashListComponent implements OnInit {
+ @ViewChild(TableComponent)
+ table: TableComponent;
+ @ViewChild('expiresTpl')
+ expiresTpl: TemplateRef;
+ @ViewChild('deleteTpl')
+ deleteTpl: TemplateRef;
+
+ columns: CdTableColumn[];
+ executingTasks: ExecutingTask[] = [];
+ images: any;
+ modalRef: BsModalRef;
+ permission: Permission;
+ retries: number;
+ selection = new CdTableSelection();
+ tableActions: CdTableAction[];
+ viewCacheStatusList: any[];
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private rbdService: RbdService,
+ private modalService: BsModalService,
+ private cdDatePipe: CdDatePipe,
+ private taskListService: TaskListService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.permission = this.authStorageService.getPermissions().rbdImage;
+
+ const restoreAction: CdTableAction = {
+ permission: 'update',
+ icon: 'fa-undo',
+ click: () => this.restoreModal(),
+ name: 'Restore'
+ };
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: 'fa-times',
+ click: () => this.deleteModal(),
+ name: 'Delete'
+ };
+ this.tableActions = [restoreAction, deleteAction];
+ }
+
+ ngOnInit() {
+ this.columns = [
+ {
+ name: 'ID',
+ prop: 'id',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.executing
+ },
+ {
+ name: 'Name',
+ prop: 'name',
+ flexGrow: 1
+ },
+ {
+ name: 'Pool',
+ prop: 'pool_name',
+ flexGrow: 1
+ },
+ {
+ name: 'Status',
+ prop: 'deferment_end_time',
+ flexGrow: 1,
+ cellTemplate: this.expiresTpl
+ },
+ {
+ name: 'Deleted At',
+ prop: 'deletion_time',
+ flexGrow: 1,
+ pipe: this.cdDatePipe
+ }
+ ];
+
+ this.taskListService.init(
+ () => this.rbdService.listTrash(),
+ (resp) => this.prepareResponse(resp),
+ (images) => (this.images = images),
+ () => this.onFetchError(),
+ this.taskFilter,
+ this.itemFilter,
+ undefined
+ );
+ }
+
+ prepareResponse(resp: any[]): any[] {
+ let images = [];
+ const viewCacheStatusMap = {};
+ resp.forEach((pool) => {
+ if (_.isUndefined(viewCacheStatusMap[pool.status])) {
+ viewCacheStatusMap[pool.status] = [];
+ }
+ viewCacheStatusMap[pool.status].push(pool.pool_name);
+ images = images.concat(pool.value);
+ });
+
+ const viewCacheStatusList = [];
+ _.forEach(viewCacheStatusMap, (value: any, key) => {
+ viewCacheStatusList.push({
+ status: parseInt(key, 10),
+ statusFor:
+ (value.length > 1 ? 'pools ' : 'pool ') +
+ '' +
+ value.join(' , ') +
+ ' '
+ });
+ });
+ this.viewCacheStatusList = viewCacheStatusList;
+ images.forEach((image) => {
+ image.cdIsExpired = moment().isAfter(image.deferment_end_time);
+ });
+ return images;
+ }
+
+ onFetchError() {
+ this.table.reset(); // Disable loading indicator.
+ this.viewCacheStatusList = [{ status: ViewCacheStatus.ValueException }];
+ }
+
+ itemFilter(entry, task) {
+ return entry.id === task.metadata['image_id'];
+ }
+
+ taskFilter(task) {
+ return ['rbd/trash/remove', 'rbd/trash/restore'].includes(task.name);
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ restoreModal() {
+ const initialState = {
+ metaType: 'RBD',
+ poolName: this.selection.first().pool_name,
+ imageName: this.selection.first().name,
+ imageId: this.selection.first().id
+ };
+
+ this.modalRef = this.modalService.show(RbdTrashRestoreModalComponent, { initialState });
+ }
+
+ deleteModal() {
+ const poolName = this.selection.first().pool_name;
+ const imageName = this.selection.first().name;
+ const imageId = this.selection.first().id;
+ const expiresAt = this.selection.first().deferment_end_time;
+
+ this.modalRef = this.modalService.show(DeletionModalComponent, {
+ initialState: {
+ itemDescription: 'RBD',
+ bodyTemplate: this.deleteTpl,
+ bodyContext: { $implicit: expiresAt },
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('rbd/trash/remove', {
+ pool_name: poolName,
+ image_id: imageId,
+ image_name: imageName
+ }),
+ call: this.rbdService.removeTrash(poolName, imageId, imageName, true)
+ })
+ }
+ });
+ }
+
+ isExpired(expiresAt): boolean {
+ return moment().isAfter(expiresAt);
+ }
+
+ purgeModal() {
+ this.modalService.show(RbdTrashPurgeModalComponent);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html
new file mode 100644
index 0000000000000..0d02e3e1dc05a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html
@@ -0,0 +1,60 @@
+
+ Move an image to trash
+
+
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.scss
new file mode 100644
index 0000000000000..94a909128a716
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.scss
@@ -0,0 +1,5 @@
+// Temprary fix until ngx-bootstrap merges: https://github.com/valor-software/ngx-bootstrap/pull/4509
+::ng-deep .bs-datepicker-head bs-datepicker-navigation-view {
+ display: flex;
+ justify-content: space-between;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.spec.ts
new file mode 100644
index 0000000000000..e0fbee793855f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.spec.ts
@@ -0,0 +1,104 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import * as moment from 'moment';
+import { ToastModule } from 'ng2-toastr';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap';
+import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { ApiModule } from '../../../shared/api/api.module';
+import { NotificationService } from '../../../shared/services/notification.service';
+import { ServicesModule } from '../../../shared/services/services.module';
+import { SharedModule } from '../../../shared/shared.module';
+import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal.component';
+
+describe('RbdTrashMoveModalComponent', () => {
+ let component: RbdTrashMoveModalComponent;
+ let fixture: ComponentFixture;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ ServicesModule,
+ ApiModule,
+ ToastModule.forRoot(),
+ BsDatepickerModule.forRoot()
+ ],
+ declarations: [RbdTrashMoveModalComponent],
+ providers: [BsModalRef, BsModalService]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTrashMoveModalComponent);
+ component = fixture.componentInstance;
+ httpTesting = TestBed.get(HttpTestingController);
+
+ component.metaType = 'RBD';
+ component.poolName = 'foo';
+ component.imageName = 'bar';
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(component.moveForm).toBeDefined();
+ });
+
+ it('should finish running ngOnInit', () => {
+ fixture.detectChanges();
+ expect(component.pattern).toEqual('foo/bar');
+ });
+
+ describe('should call moveImage', () => {
+ let notificationService;
+
+ beforeEach(() => {
+ notificationService = TestBed.get(NotificationService);
+ spyOn(notificationService, 'show').and.stub();
+ spyOn(component.modalRef, 'hide').and.callThrough();
+ });
+
+ afterEach(() => {
+ expect(notificationService.show).toHaveBeenCalledTimes(1);
+ expect(component.modalRef.hide).toHaveBeenCalledTimes(1);
+ });
+
+ it('with normal delay', () => {
+ component.moveImage();
+ const req = httpTesting.expectOne('api/block/image/foo/bar/move_trash');
+ req.flush(null);
+ expect(req.request.body).toEqual({ delay: 0 });
+ });
+
+ it('with delay < 0', () => {
+ const oldDate = moment()
+ .subtract(24, 'hour')
+ .toDate();
+ component.moveForm.patchValue({ expiresAt: oldDate });
+
+ component.moveImage();
+ const req = httpTesting.expectOne('api/block/image/foo/bar/move_trash');
+ req.flush(null);
+ expect(req.request.body).toEqual({ delay: 0 });
+ });
+
+ it('with delay < 0', () => {
+ const oldDate = moment()
+ .add(24, 'hour')
+ .toISOString();
+ fixture.detectChanges();
+ component.moveForm.patchValue({ expiresAt: oldDate });
+
+ component.moveImage();
+ const req = httpTesting.expectOne('api/block/image/foo/bar/move_trash');
+ req.flush(null);
+ expect(req.request.body.delay).toBeGreaterThan(86390);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts
new file mode 100644
index 0000000000000..75e3f39bea765
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts
@@ -0,0 +1,88 @@
+import { Component, OnInit } from '@angular/core';
+
+import * as moment from 'moment';
+import { BsModalRef } from 'ngx-bootstrap';
+
+import { RbdService } from '../../../shared/api/rbd.service';
+import { CdFormBuilder } from '../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../shared/forms/cd-validators';
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-rbd-trash-move-modal',
+ templateUrl: './rbd-trash-move-modal.component.html',
+ styleUrls: ['./rbd-trash-move-modal.component.scss']
+})
+export class RbdTrashMoveModalComponent implements OnInit {
+ metaType: string;
+ poolName: string;
+ imageName: string;
+ executingTasks: ExecutingTask[];
+
+ moveForm: CdFormGroup;
+ minDate = new Date();
+ bsConfig = {
+ dateInputFormat: 'YYYY-MM-DD HH:mm:ss',
+ containerClass: 'theme-default'
+ };
+ pattern: string;
+
+ constructor(
+ private rbdService: RbdService,
+ public modalRef: BsModalRef,
+ private fb: CdFormBuilder,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.createForm();
+ }
+
+ createForm() {
+ this.moveForm = this.fb.group({
+ expiresAt: [
+ '',
+ [
+ CdValidators.custom('format', (expiresAt) => {
+ const result = expiresAt === '' || moment(expiresAt, 'YYYY-MM-DD HH:mm:ss').isValid();
+ return !result;
+ }),
+ CdValidators.custom('expired', (expiresAt) => {
+ const result = moment().isAfter(expiresAt);
+ return result;
+ })
+ ]
+ ]
+ });
+ }
+
+ ngOnInit() {
+ this.pattern = `${this.poolName}/${this.imageName}`;
+ }
+
+ moveImage() {
+ let delay = 0;
+ const expiresAt = this.moveForm.getValue('expiresAt');
+
+ if (expiresAt) {
+ delay = moment(expiresAt).diff(moment(), 'seconds', true);
+ }
+
+ if (delay < 0) {
+ delay = 0;
+ }
+
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/trash/move', {
+ pool_name: this.poolName,
+ image_name: this.imageName
+ }),
+ call: this.rbdService.moveTrash(this.poolName, this.imageName, delay)
+ })
+ .subscribe(undefined, undefined, () => {
+ this.modalRef.hide();
+ });
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html
new file mode 100644
index 0000000000000..1a3e52ac119c8
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html
@@ -0,0 +1,53 @@
+
+ Purge Trash
+
+
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.scss
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.spec.ts
new file mode 100644
index 0000000000000..95ad297283c94
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.spec.ts
@@ -0,0 +1,105 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { BsModalRef } from 'ngx-bootstrap';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { Permission } from '../../../shared/models/permissions';
+import { NotificationService } from '../../../shared/services/notification.service';
+import { SharedModule } from '../../../shared/shared.module';
+import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal.component';
+
+describe('RbdTrashPurgeModalComponent', () => {
+ let component: RbdTrashPurgeModalComponent;
+ let fixture: ComponentFixture;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ SharedModule,
+ ToastModule.forRoot(),
+ RouterTestingModule
+ ],
+ declarations: [RbdTrashPurgeModalComponent],
+ providers: [BsModalRef]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTrashPurgeModalComponent);
+ httpTesting = TestBed.get(HttpTestingController);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it(
+ 'should finish ngOnInit',
+ fakeAsync(() => {
+ component.poolPermission = new Permission(['read', 'create', 'update', 'delete']);
+ fixture.detectChanges();
+ const req = httpTesting.expectOne('api/pool?attrs=pool_name,application_metadata');
+ req.flush([
+ {
+ application_metadata: ['foo'],
+ pool_name: 'bar'
+ },
+ {
+ application_metadata: ['rbd'],
+ pool_name: 'baz'
+ }
+ ]);
+ tick();
+ expect(component.pools).toEqual(['baz']);
+ expect(component.purgeForm).toBeTruthy();
+ })
+ );
+
+ it('should call ngOnInit without pool permissions', () => {
+ component.poolPermission = new Permission([]);
+ component.ngOnInit();
+ httpTesting.expectOne('api/summary');
+ httpTesting.verify();
+ });
+
+ describe('should call purge', () => {
+ let notificationService: NotificationService;
+ let modalRef: BsModalRef;
+ let req;
+
+ beforeEach(() => {
+ fixture.detectChanges();
+ notificationService = TestBed.get(NotificationService);
+ modalRef = TestBed.get(BsModalRef);
+
+ component.purgeForm.patchValue({ poolName: 'foo' });
+
+ spyOn(modalRef, 'hide').and.stub();
+ spyOn(component.purgeForm, 'setErrors').and.stub();
+ spyOn(notificationService, 'show').and.stub();
+
+ component.purge();
+
+ req = httpTesting.expectOne('api/block/image/trash/purge/?pool_name=foo');
+ });
+
+ it('with success', () => {
+ req.flush(null);
+ expect(component.purgeForm.setErrors).toHaveBeenCalledTimes(0);
+ expect(component.modalRef.hide).toHaveBeenCalledTimes(1);
+ });
+
+ it('with failure', () => {
+ req.flush(null, { status: 500, statusText: 'failure' });
+ expect(component.purgeForm.setErrors).toHaveBeenCalledTimes(1);
+ expect(component.modalRef.hide).toHaveBeenCalledTimes(0);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.ts
new file mode 100644
index 0000000000000..cda71e538c30a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.ts
@@ -0,0 +1,72 @@
+import { Component, OnInit } from '@angular/core';
+
+import { BsModalRef } from 'ngx-bootstrap';
+
+import { PoolService } from '../../../shared/api/pool.service';
+import { RbdService } from '../../../shared/api/rbd.service';
+import { CdFormBuilder } from '../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { Permission } from '../../../shared/models/permissions';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-rbd-trash-purge-modal',
+ templateUrl: './rbd-trash-purge-modal.component.html',
+ styleUrls: ['./rbd-trash-purge-modal.component.scss']
+})
+export class RbdTrashPurgeModalComponent implements OnInit {
+ poolPermission: Permission;
+ purgeForm: CdFormGroup;
+ pools: any[];
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private rbdService: RbdService,
+ public modalRef: BsModalRef,
+ private fb: CdFormBuilder,
+ private poolService: PoolService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.poolPermission = this.authStorageService.getPermissions().pool;
+ }
+
+ createForm() {
+ this.purgeForm = this.fb.group({
+ poolName: ''
+ });
+ }
+
+ ngOnInit() {
+ if (this.poolPermission.read) {
+ this.poolService.list(['pool_name', 'application_metadata']).then((resp) => {
+ this.pools = resp
+ .filter((pool) => pool.application_metadata.includes('rbd'))
+ .map((pool) => pool.pool_name);
+ });
+ }
+
+ this.createForm();
+ }
+
+ purge() {
+ const poolName = this.purgeForm.getValue('poolName') || '';
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/trash/purge', {
+ pool_name: poolName
+ }),
+ call: this.rbdService.purgeTrash(poolName)
+ })
+ .subscribe(
+ undefined,
+ () => {
+ this.purgeForm.setErrors({ cdSubmitButton: true });
+ },
+ () => {
+ this.modalRef.hide();
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html
new file mode 100644
index 0000000000000..42527905d652a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html
@@ -0,0 +1,53 @@
+
+ Restore Image
+
+
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.scss
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.spec.ts
new file mode 100644
index 0000000000000..91c5e83642600
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.spec.ts
@@ -0,0 +1,75 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { BsModalRef } from 'ngx-bootstrap';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { NotificationService } from '../../../shared/services/notification.service';
+import { SharedModule } from '../../../shared/shared.module';
+import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal.component';
+
+describe('RbdTrashRestoreModalComponent', () => {
+ let component: RbdTrashRestoreModalComponent;
+ let fixture: ComponentFixture;
+
+ configureTestBed({
+ declarations: [RbdTrashRestoreModalComponent],
+ imports: [
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ ToastModule.forRoot(),
+ SharedModule,
+ RouterTestingModule
+ ],
+ providers: [BsModalRef]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(RbdTrashRestoreModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('should call restore', () => {
+ let httpTesting: HttpTestingController;
+ let notificationService: NotificationService;
+ let modalRef: BsModalRef;
+ let req;
+
+ beforeEach(() => {
+ httpTesting = TestBed.get(HttpTestingController);
+ notificationService = TestBed.get(NotificationService);
+ modalRef = TestBed.get(BsModalRef);
+
+ component.poolName = 'foo';
+ component.imageId = 'bar';
+
+ spyOn(modalRef, 'hide').and.stub();
+ spyOn(component.restoreForm, 'setErrors').and.stub();
+ spyOn(notificationService, 'show').and.stub();
+
+ component.restore();
+
+ req = httpTesting.expectOne('api/block/image/trash/foo/bar/restore');
+ });
+
+ it('with success', () => {
+ req.flush(null);
+ expect(component.restoreForm.setErrors).toHaveBeenCalledTimes(0);
+ expect(component.modalRef.hide).toHaveBeenCalledTimes(1);
+ });
+
+ it('with failure', () => {
+ req.flush(null, { status: 500, statusText: 'failure' });
+ expect(component.restoreForm.setErrors).toHaveBeenCalledTimes(1);
+ expect(component.modalRef.hide).toHaveBeenCalledTimes(0);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.ts
new file mode 100644
index 0000000000000..b1d0ff6953fe9
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.ts
@@ -0,0 +1,62 @@
+import { Component, OnInit } from '@angular/core';
+
+import { BsModalRef } from 'ngx-bootstrap';
+
+import { RbdService } from '../../../shared/api/rbd.service';
+import { CdFormBuilder } from '../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-rbd-trash-restore-modal',
+ templateUrl: './rbd-trash-restore-modal.component.html',
+ styleUrls: ['./rbd-trash-restore-modal.component.scss']
+})
+export class RbdTrashRestoreModalComponent implements OnInit {
+ metaType: string;
+ poolName: string;
+ imageName: string;
+ imageId: string;
+ executingTasks: ExecutingTask[];
+
+ restoreForm: CdFormGroup;
+
+ constructor(
+ private rbdService: RbdService,
+ public modalRef: BsModalRef,
+ private fb: CdFormBuilder,
+ private taskWrapper: TaskWrapperService
+ ) {}
+
+ ngOnInit() {
+ this.restoreForm = this.fb.group({
+ name: this.imageName
+ });
+ }
+
+ restore() {
+ const name = this.restoreForm.getValue('name');
+
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rbd/trash/restore', {
+ pool_name: this.poolName,
+ image_id: this.imageId,
+ image_name: this.imageName,
+ new_image_name: name
+ }),
+ call: this.rbdService.restoreTrash(this.poolName, this.imageId, name)
+ })
+ .subscribe(
+ undefined,
+ () => {
+ this.restoreForm.setErrors({ cdSubmitButton: true });
+ },
+ () => {
+ this.modalRef.hide();
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts
index f43e05293ba11..b7ced8049b2c9 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts
@@ -62,7 +62,7 @@ export class RgwBucketListComponent {
};
const deleteAction: CdTableAction = {
permission: 'delete',
- icon: 'fa-trash-o',
+ icon: 'fa-times',
click: () => this.deleteAction(),
name: 'Delete'
};
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
index 45b4f0ae61f7f..b4b5e60af608a 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
@@ -265,7 +265,7 @@
i18n-tooltip
tooltip="Delete"
(click)="deleteSubuser(i)">
-
+
@@ -322,7 +322,7 @@
i18n-tooltip
tooltip="Delete"
(click)="deleteS3Key(i)">
-
+
@@ -412,7 +412,7 @@
i18n-tooltip
tooltip="Delete"
(click)="deleteCapability(i)">
-
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts
index cc9edcc2693b4..485ccf214ffeb 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts
@@ -78,7 +78,7 @@ export class RgwUserListComponent {
};
const deleteAction: CdTableAction = {
permission: 'delete',
- icon: 'fa-trash-o',
+ icon: 'fa-times',
click: () => this.deleteAction(),
name: 'Delete'
};
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts
index dcb43df514a4f..e0857fe5777de 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts
@@ -56,7 +56,7 @@ export class RoleListComponent implements OnInit {
};
const deleteAction: CdTableAction = {
permission: 'delete',
- icon: 'fa-trash-o',
+ icon: 'fa-times',
disable: () => !this.selection.hasSingleSelection || this.selection.first().system,
click: () => this.deleteRoleModal(),
name: 'Delete'
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts
index 5fd4abeb3d0c4..bdce37578083e 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts
@@ -53,7 +53,7 @@ export class UserListComponent implements OnInit {
};
const deleteAction: CdTableAction = {
permission: 'delete',
- icon: 'fa-trash-o',
+ icon: 'fa-times',
click: () => this.deleteUserModal(),
name: 'Delete'
};
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts
index dcf2787685f33..1fba5e58d25fb 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts
@@ -126,4 +126,11 @@ describe('RbdService', () => {
const req = httpTesting.expectOne('api/block/image/poolName/rbdName/snap/snapshotName');
expect(req.request.method).toBe('DELETE');
});
+
+ it('should call moveTrash', () => {
+ service.moveTrash('poolName', 'rbdName', 1).subscribe();
+ const req = httpTesting.expectOne('api/block/image/poolName/rbdName/move_trash');
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual({ delay: 1 });
+ });
});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts
index 93decddec2a94..02e1bcdc1522e 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts
@@ -95,4 +95,37 @@ export class RbdService {
observe: 'response'
});
}
+
+ listTrash() {
+ return this.http.get(`api/block/image/trash/`);
+ }
+
+ moveTrash(poolName, rbdName, delay) {
+ return this.http.post(
+ `api/block/image/${poolName}/${rbdName}/move_trash`,
+ { delay: delay },
+ { observe: 'response' }
+ );
+ }
+
+ purgeTrash(poolName) {
+ return this.http.post(`api/block/image/trash/purge/?pool_name=${poolName}`, null, {
+ observe: 'response'
+ });
+ }
+
+ restoreTrash(poolName, imageId, newImageName) {
+ return this.http.post(
+ `api/block/image/trash/${poolName}/${imageId}/restore`,
+ { new_image_name: newImageName },
+ { observe: 'response' }
+ );
+ }
+
+ removeTrash(poolName, imageId, imageName, force = false) {
+ return this.http.delete(
+ `api/block/image/trash/${poolName}/${imageId}/?image_name=${imageName}&force=${force}`,
+ { observe: 'response' }
+ );
+ }
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.html
index ee4532677f94b..d12d9006bba23 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.html
@@ -10,7 +10,7 @@
[formGroup]="deletionForm"
novalidate>
-
+
Are you sure that you want to {{ actionDescription | lowercase }} the selected {{ itemDescription }}?
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.spec.ts
index 7e27c85ba4f23..75635444f770c 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.spec.ts
@@ -20,7 +20,7 @@ export class MockModule {}
- Deletion Ctrl-Test
+ Deletion Ctrl-Test
The spinner is handled by the controller if you have use the modal as ViewChild in order to
use it's functions to stop the spinner or close the dialog.
@@ -30,7 +30,7 @@ export class MockModule {}
- Deletion Modal-Test
+ Deletion Modal-Test
The spinner is handled by the modal if your given deletion function returns a Observable.
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.ts
index f472baccbb7d7..3206503674024 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.ts
@@ -16,6 +16,7 @@ export class DeletionModalComponent implements OnInit {
@ViewChild(SubmitButtonComponent)
submitButton: SubmitButtonComponent;
bodyTemplate: TemplateRef;
+ bodyContext: any;
submitActionObservable: () => Observable;
submitAction: Function;
deletionForm: CdFormGroup;
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts
index 970bb421ec1a7..cdd1cee67aced 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts
@@ -64,7 +64,7 @@ describe('TableActionsComponent', () => {
};
deleteAction = {
permission: 'delete',
- icon: 'fa-trash-o',
+ icon: 'fa-times',
buttonCondition: (selection: CdTableSelection) => selection.hasSelection,
disable: (selection: CdTableSelection) =>
!selection.hasSelection || selection.first().cdExecuting,
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts
index 6b9e355b042f4..20d5a962e3657 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts
@@ -54,7 +54,7 @@ export class TaskListService implements OnDestroy {
this.onFetchError = onFetchError;
this.taskFilter = taskFilter;
this.itemFilter = itemFilter;
- this.builders = builders;
+ this.builders = builders || {};
this.summaryDataSubscription = this.summaryService.subscribe((tasks: any) => {
if (tasks) {
@@ -76,7 +76,7 @@ export class TaskListService implements OnDestroy {
}
private addMissing(data: any[], tasks: ExecutingTask[]) {
- const defaultBuilder = this.builders['default'];
+ const defaultBuilder = this.builders['default'] || {};
tasks.forEach((task) => {
const existing = data.find((item) => this.itemFilter(item, task));
const builder = this.builders[task.name];
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
index cb4308da0b04c..517b0865913fc 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
@@ -126,6 +126,36 @@ export class TaskMessageService {
'rbd/snap/rollback': new TaskMessage(
new TaskMessageOperation('Rolling back', 'rollback', 'Rolled back'),
this.rbd.snapshot
+ ),
+ 'rbd/trash/move': new TaskMessage(
+ new TaskMessageOperation('Moving', 'move', 'Moved'),
+ (metadata) => `image '${metadata.pool_name}/${metadata.image_name}' to trash`,
+ () => ({
+ 2: `Could not find image.`
+ })
+ ),
+ 'rbd/trash/restore': new TaskMessage(
+ new TaskMessageOperation('Restoring', 'restore', 'Restored'),
+ (metadata) =>
+ `image '${metadata.pool_name}/${metadata.image_name}@${metadata.image_id}' \
+ into '${metadata.pool_name}/${metadata.new_image_name}'`,
+ (metadata) => ({
+ 17: `Image name '${metadata.pool_name}/${metadata.new_image_name}' is already in use.`
+ })
+ ),
+ 'rbd/trash/remove': new TaskMessage(
+ new TaskMessageOperation('Deleting', 'delete', 'Deleted'),
+ (metadata) => `image '${metadata.pool_name}/${metadata.image_name}@${metadata.image_id}'`
+ ),
+ 'rbd/trash/purge': new TaskMessage(
+ new TaskMessageOperation('Purging', 'purge', 'Purged'),
+ (metadata) => {
+ let message = 'all pools';
+ if (metadata.pool_name) {
+ message = `'${metadata.pool_name}'`;
+ }
+ return `images from ${message}`;
+ }
)
};