mirror of
https://github.com/ceph/ceph
synced 2025-02-22 10:37:15 +00:00
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:
parent
6bb951b1bf
commit
31361a5e0d
@ -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": [
|
||||
|
@ -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 {}
|
||||
|
@ -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]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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>
|
||||
<kbd>{{ poolName }}/{{ imageName }}</kbd>
|
||||
<ng-container i18n>to trash, click</ng-container>
|
||||
<kbd i18n>Move Image</kbd>.
|
||||
<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>
|
@ -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;
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
@ -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' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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.`
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user