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 b9c97d6badf..36606c6f302 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 @@ -19,6 +19,7 @@ import { RbdSnapshotFormComponent } from './rbd-snapshot-form/rbd-snapshot-form. 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({ @@ -26,7 +27,8 @@ import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-tra RbdDetailsComponent, RbdSnapshotFormComponent, RbdTrashMoveModalComponent, - RbdTrashRestoreModalComponent + RbdTrashRestoreModalComponent, + RbdTrashPurgeModalComponent ], imports: [ CommonModule, @@ -53,7 +55,8 @@ import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-tra RbdTrashListComponent, RbdTrashMoveModalComponent, RbdImagesComponent, - RbdTrashRestoreModalComponent + RbdTrashRestoreModalComponent, + RbdTrashPurgeModalComponent ] }) export class BlockModule {} 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 index e033ab841ff..a16ece60f8b 100644 --- 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 @@ -15,6 +15,14 @@ [selection]="selection" [tableActions]="tableActions"> </cd-table-actions> + + <button class="btn btn-sm btn-default btn-label" + type="button" + (click)="purgeModal()"> + <i class="fa fa-fw fa-times" + aria-hidden="true"></i> + <ng-container i18n>Purge Trash</ng-container> + </button> </div> </cd-table> 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 index 6f0f6437c6f..6a505b3f6bd 100644 --- 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 @@ -19,6 +19,7 @@ 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({ @@ -197,4 +198,8 @@ export class RbdTrashListComponent implements OnInit { 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-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 00000000000..1a3e52ac119 --- /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 @@ +<cd-modal> + <ng-container i18n + class="modal-title">Purge Trash</ng-container> + + <ng-container class="modal-content"> + <form name="purgeForm" + class="form" + #formDir="ngForm" + [formGroup]="purgeForm" + novalidate> + <div class="modal-body"> + <p> + <ng-container i18n>To purge, select one or All images and click</ng-container> + <kbd i18n>Purge Trash</kbd>. + </p> + + <div class="form-group"> + <label class="center-block" + i18n>Pool: + </label> + <input class="form-control" + type="text" + placeholder="Pool name..." + i18n-placeholder + formControlName="poolName" + *ngIf="!poolPermission.read"> + <select class="form-control" + formControlName="poolName" + *ngIf="poolPermission.read"> + <option value="" + i18n>All</option> + <option *ngFor="let pool of pools" + [value]="pool">{{ pool }}</option> + </select> + </div> + </div> + + <div class="modal-footer"> + <div class="button-group text-right"> + <cd-submit-button i18n + [form]="purgeForm" + (submitAction)="purge()"> + Purge Trash + </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> 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 00000000000..e69de29bb2d 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 00000000000..95ad297283c --- /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<RbdTrashPurgeModalComponent>; + 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 00000000000..cda71e538c3 --- /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/shared/api/rbd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts index 765d2054f86..02e1bcdc152 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 @@ -108,6 +108,12 @@ export class RbdService { ); } + 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`,