]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add UI for RBD Trash Move
authorTiago Melo <tspmelo@gmail.com>
Tue, 31 Jul 2018 16:00:16 +0000 (17:00 +0100)
committerTiago Melo <tspmelo@gmail.com>
Tue, 25 Sep 2018 13:02:58 +0000 (14:02 +0100)
Fixes: http://tracker.ceph.com/issues/24272
Signed-off-by: Tiago Melo <tmelo@suse.com>
src/pybind/mgr/dashboard/frontend/angular.json
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

index 35d23369aa4d2b35ec4318119377ebde915297d8..3b30176bfec96f958b26dcad8644999f5e3f7ead 100644 (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": [
index 78b2c4a013dc6deb1366c4330650c5daa7663b1c..e6300ee09fec9cf206cf5fc5d7ee007e4147d580 100644 (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 {}
index c1947a10109e1435e9fde09f0160d81ceab7f616..b5d259ea99a73b3a3cd9000544b05bc3dbe42c25 100644 (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]
+        ]);
       });
     });
 
index 4630cf0974f4200b74bb82bbb0371d6b6afa63bf..5db4a6a67e2d8f575f0320d86dc9eacbbf327483 100644 (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({
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 (file)
index 0000000..0d02e3e
--- /dev/null
@@ -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>
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 (file)
index 0000000..94a9091
--- /dev/null
@@ -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 (file)
index 0000000..e0fbee7
--- /dev/null
@@ -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);
+    });
+  });
+});
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 (file)
index 0000000..75e3f39
--- /dev/null
@@ -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();
+      });
+  }
+}
index dcf2787685f3386ead5170136aabb2390f71a9dd..1fba5e58d25fbbf0c2896b4a8ca81a1f4d212776 100644 (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 });
+  });
 });
index 93decddec2a946c6eae3692db4439ba5de268bc2..853fdf9b6b766c96aff1ff4eaa5eb8f96485f346 100644 (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' }
+    );
+  }
 }
index cb4308da0b04c81225bfe85517e0d4ce77834fa8..4da4c6c5dd2ba1cde89c9a68cb01c525db0774a4 100644 (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.`
+      })
     )
   };