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