mgr/dashboard: Add UI for RBD Trash Move

Fixes: http://tracker.ceph.com/issues/24272

Signed-off-by: Tiago Melo <tmelo@suse.com>
This commit is contained in:
Tiago Melo 2018-07-31 17:00:16 +01:00
parent 6bb951b1bf
commit 31361a5e0d
11 changed files with 335 additions and 15 deletions

View File

@ -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": [

View File

@ -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 {}

View File

@ -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]
]);
});
});

View File

@ -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({

View File

@ -0,0 +1,60 @@
<cd-modal>
<ng-container i18n
class="modal-title">Move an image to trash</ng-container>
<ng-container class="modal-content">
<form name="moveForm"
class="form"
#formDir="ngForm"
[formGroup]="moveForm"
novalidate>
<div class="modal-body">
<p>
<ng-container i18n>To move</ng-container>&nbsp;
<kbd>{{ poolName }}/{{ imageName }}</kbd>&nbsp;
<ng-container i18n>to trash, click</ng-container>&nbsp;
<kbd i18n>Move Image</kbd>.&nbsp;
<ng-container i18n>Optionally, you can pick an expiration date.</ng-container>
</p>
<div class="form-group"
[ngClass]="{'has-error': moveForm.showError('expiresAt', formDir)}">
<label for="expires"
i18n>Protection expires at</label>
<input type="text"
placeholder="NOT PROTECTED"
i18n-placeholder
class="form-control"
[minDate]="minDate"
[bsConfig]="bsConfig"
formControlName="expiresAt"
bsDatepicker>
<span i18n
class="help-block"
*ngIf="moveForm.showError('expiresAt', formDir, 'format')">
Wrong date format. Please use "YYYY-MM-DD HH:mm:ss".
</span>
<span i18n
class="help-block"
*ngIf="moveForm.showError('expiresAt', formDir, 'expired')">
Protection has already expired. Please pick a future date or leave it empty.
</span>
</div>
</div>
<div class="modal-footer">
<div class="button-group text-right">
<cd-submit-button i18n
[form]="moveForm"
(submitAction)="moveImage()">
Move Image
</cd-submit-button>
<button i18n
type="button"
class="btn btn-sm btn-default"
(click)="modalRef.hide()">Cancel</button>
</div>
</div>
</form>
</ng-container>
</cd-modal>

View File

@ -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;
}

View File

@ -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<RbdTrashMoveModalComponent>;
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);
});
});
});

View File

@ -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();
});
}
}

View File

@ -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 });
});
});

View File

@ -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' }
);
}
}

View File

@ -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.`
})
)
};