From 31361a5e0df7689c530cf61437063583a5ea338e Mon Sep 17 00:00:00 2001 From: Tiago Melo Date: Tue, 31 Jul 2018 17:00:16 +0100 Subject: [PATCH] mgr/dashboard: Add UI for RBD Trash Move Fixes: http://tracker.ceph.com/issues/24272 Signed-off-by: Tiago Melo --- .../mgr/dashboard/frontend/angular.json | 1 + .../src/app/ceph/block/block.module.ts | 12 +- .../block/rbd-list/rbd-list.component.spec.ts | 28 +++-- .../ceph/block/rbd-list/rbd-list.component.ts | 30 ++++- .../rbd-trash-move-modal.component.html | 60 ++++++++++ .../rbd-trash-move-modal.component.scss | 5 + .../rbd-trash-move-modal.component.spec.ts | 104 ++++++++++++++++++ .../rbd-trash-move-modal.component.ts | 88 +++++++++++++++ .../src/app/shared/api/rbd.service.spec.ts | 7 ++ .../src/app/shared/api/rbd.service.ts | 8 ++ .../shared/services/task-message.service.ts | 7 ++ 11 files changed, 335 insertions(+), 15 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts 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/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index 78b2c4a013dc6..e6300ee09fec9 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'; @@ -15,9 +16,14 @@ import { RbdFormComponent } from './rbd-form/rbd-form.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 { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-move-modal.component'; @NgModule({ - entryComponents: [RbdDetailsComponent, RbdSnapshotFormComponent], + entryComponents: [ + RbdDetailsComponent, + RbdSnapshotFormComponent, + RbdTrashMoveModalComponent + ], imports: [ CommonModule, FormsModule, @@ -25,6 +31,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 +45,8 @@ import { RbdSnapshotListComponent } from './rbd-snapshot-list/rbd-snapshot-list. RbdDetailsComponent, RbdFormComponent, RbdSnapshotListComponent, - RbdSnapshotFormComponent + RbdSnapshotFormComponent, + RbdTrashMoveModalComponent ] }) export class BlockModule {} 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 4630cf0974f42..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({ @@ -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-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/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..853fdf9b6b766 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,12 @@ export class RbdService { observe: 'response' }); } + + moveTrash(poolName, rbdName, delay) { + return this.http.post( + `api/block/image/${poolName}/${rbdName}/move_trash`, + { delay: delay }, + { observe: 'response' } + ); + } } 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..4da4c6c5dd2ba 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,13 @@ 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.` + }) ) }; -- 2.39.5