]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add support for managing individual OSD settings/characteristics in... 24606/head
authorPatrick Nawracay <pnawracay@suse.com>
Fri, 5 Oct 2018 13:35:12 +0000 (15:35 +0200)
committerPatrick Nawracay <pnawracay@suse.com>
Thu, 25 Oct 2018 07:07:48 +0000 (09:07 +0200)
Fixes: http://tracker.ceph.com/issues/35448
Signed-off-by: Patrick Nawracay <pnawracay@suse.com>
29 files changed:
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-action.ts
src/pybind/mgr/dashboard/frontend/src/styles/popover.scss
src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts

index 1807c98ab9bb812e646773528c80cf51efdcd0b9..80807c35d71e8b225919095a85bd365f8940261f 100644 (file)
@@ -73,9 +73,15 @@ const routes: Routes = [
   },
   {
     path: 'osd',
-    component: OsdListComponent,
     canActivate: [AuthGuardService],
-    data: { breadcrumbs: 'Cluster/OSDs' }
+    canActivateChild: [AuthGuardService],
+    data: { breadcrumbs: 'Cluster/OSDs' },
+    children: [
+      {
+        path: '',
+        component: OsdListComponent
+      }
+    ]
   },
   {
     path: 'configuration',
index 748568eb768f0633d97af600d17f65b20021e723..2d60ff99e64dd4246cbd4dd23f9bf9b251f008c8 100644 (file)
@@ -3,9 +3,11 @@ import { NgModule } from '@angular/core';
 import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 import { RouterModule } from '@angular/router';
 
+import { AlertModule } from 'ngx-bootstrap/alert';
 import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
 import { ModalModule } from 'ngx-bootstrap/modal';
 import { TabsModule } from 'ngx-bootstrap/tabs';
+import { TooltipModule } from 'ngx-bootstrap/tooltip';
 
 import { SharedModule } from '../../shared/shared.module';
 import { PerformanceCounterModule } from '../performance-counter/performance-counter.module';
@@ -19,10 +21,16 @@ import { OsdDetailsComponent } from './osd/osd-details/osd-details.component';
 import { OsdFlagsModalComponent } from './osd/osd-flags-modal/osd-flags-modal.component';
 import { OsdListComponent } from './osd/osd-list/osd-list.component';
 import { OsdPerformanceHistogramComponent } from './osd/osd-performance-histogram/osd-performance-histogram.component';
+import { OsdReweightModalComponent } from './osd/osd-reweight-modal/osd-reweight-modal.component';
 import { OsdScrubModalComponent } from './osd/osd-scrub-modal/osd-scrub-modal.component';
 
 @NgModule({
-  entryComponents: [OsdDetailsComponent, OsdScrubModalComponent, OsdFlagsModalComponent],
+  entryComponents: [
+    OsdDetailsComponent,
+    OsdScrubModalComponent,
+    OsdFlagsModalComponent,
+    OsdReweightModalComponent
+  ],
   imports: [
     CommonModule,
     PerformanceCounterModule,
@@ -32,7 +40,9 @@ import { OsdScrubModalComponent } from './osd/osd-scrub-modal/osd-scrub-modal.co
     FormsModule,
     ReactiveFormsModule,
     BsDropdownModule.forRoot(),
-    ModalModule.forRoot()
+    ModalModule.forRoot(),
+    AlertModule.forRoot(),
+    TooltipModule.forRoot()
   ],
   declarations: [
     HostsComponent,
@@ -45,7 +55,8 @@ import { OsdScrubModalComponent } from './osd/osd-scrub-modal/osd-scrub-modal.co
     OsdFlagsModalComponent,
     HostDetailsComponent,
     ConfigurationDetailsComponent,
-    ConfigurationFormComponent
+    ConfigurationFormComponent,
+    OsdReweightModalComponent
   ]
 })
 export class ClusterModule {}
index dfedb8d0f83a6c425e7a09811faff6b7af249c56..00821bd2400be703548b97491abab9155f73d45d 100644 (file)
                       i18n>
                   {{ patternHelpText }}
                 </span>
+                <span class="help-block"
+                      *ngIf="configForm.showError(section, formDir, 'invalidUuid')"
+                      i18n>
+                  {{ patternHelpText }}
+                </span>
                 <span class="help-block"
                       *ngIf="configForm.showError(section, formDir, 'max')"
                       i18n>
index 61b146e6b5652b16febddbacee0a46aec53552ff..38d31c0c03a3dd2fc3aa52f686c54c0f0c9b4989 100644 (file)
@@ -7,10 +7,9 @@
               selectionType="single"
               (updateSelection)="updateSelection($event)"
               [updateSelectionOnRefresh]="'never'">
-      <div class="table-actions btn-toolbar" *ngIf="permission.update">
+      <div class="table-actions btn-toolbar">
         <cd-table-actions [permission]="permission"
                           [selection]="selection"
-                          onlyDropDown="Perform Task"
                           class="btn-group"
                           [tableActions]="tableActions">
         </cd-table-actions>
     </cd-grafana>
   </tab>
 </tabset>
+
+<ng-template #osdUsageTpl
+             let-row="row">
+  <cd-usage-bar [totalBytes]="row.stats.stat_bytes"
+                [usedBytes]="row.stats.stat_bytes_used"></cd-usage-bar>
+</ng-template>
+
+<ng-template #markOsdConfirmationTpl i18n let-markActionDescription="markActionDescription">
+  <strong>OSD {{ selection.first().id }}</strong> will be marked
+  <strong>{{ markActionDescription }}</strong> if you proceed.
+</ng-template>
+
+<ng-template #criticalConfirmationTpl
+             i18n
+             let-safeToDestroyResult="result"
+             let-actionDescription="actionDescription">
+  <div *ngIf="!safeToDestroyResult['safe-to-destroy']"
+       class="danger">
+    <cd-warning-panel>
+      {{ safeToDestroyResult.message }}
+    </cd-warning-panel>
+  </div>
+  <strong>OSD {{ selection.first().id }}</strong> will be
+  <strong>{{ actionDescription }}</strong> if you proceed.
+</ng-template>
index a8c293b97a90fe3a71a36a64e94f41d0cf1fd4dc..2814989c12d3d89d12955ebd8f2534d64032edc0 100644 (file)
@@ -1,23 +1,33 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { DebugElement } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
 import { By } from '@angular/platform-browser';
 import { RouterTestingModule } from '@angular/router/testing';
 
+import { BsModalService } from 'ngx-bootstrap/modal';
 import { TabsModule } from 'ngx-bootstrap/tabs';
+import { EMPTY, of } from 'rxjs';
 
 import { configureTestBed, PermissionHelper } from '../../../../../testing/unit-test-helper';
+import { OsdService } from '../../../../shared/api/osd.service';
+import { ConfirmationModalComponent } from '../../../../shared/components/confirmation-modal/confirmation-modal.component';
+import { CriticalConfirmationModalComponent } from '../../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { TableActionsComponent } from '../../../../shared/datatable/table-actions/table-actions.component';
+import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
 import { Permissions } from '../../../../shared/models/permissions';
 import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
 import { SharedModule } from '../../../../shared/shared.module';
 import { PerformanceCounterModule } from '../../../performance-counter/performance-counter.module';
 import { OsdDetailsComponent } from '../osd-details/osd-details.component';
 import { OsdPerformanceHistogramComponent } from '../osd-performance-histogram/osd-performance-histogram.component';
+import { OsdReweightModalComponent } from '../osd-reweight-modal/osd-reweight-modal.component';
 import { OsdListComponent } from './osd-list.component';
 
 describe('OsdListComponent', () => {
   let component: OsdListComponent;
   let fixture: ComponentFixture<OsdListComponent>;
+  let modalServiceShowSpy: jasmine.Spy;
 
   const fakeAuthStorageService = {
     getPermissions: () => {
@@ -31,17 +41,36 @@ describe('OsdListComponent', () => {
       PerformanceCounterModule,
       TabsModule.forRoot(),
       SharedModule,
+      ReactiveFormsModule,
       RouterTestingModule
     ],
     declarations: [OsdListComponent, OsdDetailsComponent, OsdPerformanceHistogramComponent],
-    providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }]
+    providers: [
+      { provide: AuthStorageService, useValue: fakeAuthStorageService },
+      TableActionsComponent,
+      BsModalService
+    ]
   });
 
   beforeEach(() => {
     fixture = TestBed.createComponent(OsdListComponent);
+    fixture.detectChanges();
     component = fixture.componentInstance;
+    modalServiceShowSpy = spyOn(TestBed.get(BsModalService), 'show').and.stub();
   });
 
+  const setFakeSelection = () => {
+    // Default data and selection
+    const selection = [{ id: 1 }];
+    const data = [{ id: 1 }];
+
+    // Table data and selection
+    component.selection = new CdTableSelection();
+    component.selection.selected = selection;
+    component.selection.update();
+    component.osds = data;
+  };
+
   it('should create', () => {
     fixture.detectChanges();
     expect(component).toBeTruthy();
@@ -62,25 +91,123 @@ describe('OsdListComponent', () => {
         getTableActionComponent()
       );
       scenario = {
-        fn: () => tableActions.getCurrentButton(),
-        single: undefined,
-        empty: undefined
+        fn: () => tableActions.getCurrentButton().name,
+        single: 'Scrub',
+        empty: 'Scrub'
       };
       tableActions = permissionHelper.setPermissionsAndGetActions(1, 1, 1);
     });
 
-    it('shows no action button', () => permissionHelper.testScenarios(scenario));
+    it('shows action button', () => permissionHelper.testScenarios(scenario));
 
     it('shows all actions', () => {
-      expect(tableActions.tableActions.length).toBe(2);
+      expect(tableActions.tableActions.length).toBe(9);
       expect(tableActions.tableActions).toEqual(component.tableActions);
     });
+  });
+
+  describe('test table actions in submenu', () => {
+    beforeEach(
+      fakeAsync(() => {
+        // The menu needs a click to render the dropdown!
+        const dropDownToggle = fixture.debugElement.query(By.css('.dropdown-toggle'));
+        dropDownToggle.triggerEventHandler('click', null);
+        tick();
+        fixture.detectChanges();
+      })
+    );
+
+    /**
+     * Helper function to retrieve menu item
+     * @param selector
+     */
+    const getMenuItem = (selector: string): DebugElement => {
+      return fixture.debugElement
+        .query(By.directive(TableActionsComponent))
+        .query(By.css(selector));
+    };
+
+    it('has menu entries disabled for entries without create permission', () => {
+      component.tableActions
+        .filter((tableAction) => tableAction.permission !== 'create')
+        .map((tableAction) => tableAction.name)
+        .map(TestBed.get(TableActionsComponent).toClassName)
+        .map((className) => getMenuItem(`.${className}`))
+        .forEach((debugElement) => {
+          expect(debugElement.classes.disabled).toBe(true);
+        });
+    });
+  });
+
+  describe('tests if all modals are opened correctly', () => {
+    /**
+     * Helper function to check if a function opens a modal
+     * @param fn
+     * @param modalClass - The expected class of the modal
+     */
+    const expectOpensModal = (fn, modalClass): void => {
+      setFakeSelection();
+      fn();
+
+      expect(modalServiceShowSpy.calls.any()).toBe(true, 'modalService.show called');
+      expect(modalServiceShowSpy.calls.first()).toBeTruthy();
+      expect(modalServiceShowSpy.calls.first().args[0]).toBe(modalClass);
+
+      modalServiceShowSpy.calls.reset();
+    };
+
+    it('opens the appropriate modal', () => {
+      expectOpensModal(() => component.reweight(), OsdReweightModalComponent);
+      expectOpensModal(() => component.markOut(), ConfirmationModalComponent);
+      expectOpensModal(() => component.markIn(), ConfirmationModalComponent);
+      expectOpensModal(() => component.markDown(), ConfirmationModalComponent);
+
+      // The following modals are called after the information about their
+      // safety to destroy/remove/mark them lost has been retrieved, hence
+      // we will have to fake its request to be able to open those modals.
+      spyOn(TestBed.get(OsdService), 'safeToDestroy').and.callFake(() =>
+        of({ 'safe-to-destroy': true })
+      );
+
+      expectOpensModal(() => component.markLost(), CriticalConfirmationModalComponent);
+      expectOpensModal(() => component.remove(), CriticalConfirmationModalComponent);
+      expectOpensModal(() => component.destroy(), CriticalConfirmationModalComponent);
+    });
+  });
+
+  describe('tests if the correct methods are called on confirmation', () => {
+    const expectOsdServiceMethodCalled = (fn: Function, osdServiceMethodName: string): void => {
+      setFakeSelection();
+      const osdServiceSpy = spyOn(TestBed.get(OsdService), osdServiceMethodName).and.callFake(
+        () => EMPTY
+      );
+
+      modalServiceShowSpy.calls.reset();
+      fn(); // calls show on BsModalService
+      // Calls onSubmit given to `bsModalService.show()`
+      const initialState = modalServiceShowSpy.calls.first().args[1].initialState;
+      const action = initialState.onSubmit || initialState.submitAction;
+      action.call(component);
+
+      expect(osdServiceSpy.calls.count()).toBe(1);
+      expect(osdServiceSpy.calls.first().args[0]).toBe(1);
+      modalServiceShowSpy.calls.reset();
+      osdServiceSpy.calls.reset();
+    };
+
+    it('calls the corresponding service methods', () => {
+      // Purposely `reweight`
+      expectOsdServiceMethodCalled(() => component.markOut(), 'markOut');
+      expectOsdServiceMethodCalled(() => component.markIn(), 'markIn');
+      expectOsdServiceMethodCalled(() => component.markDown(), 'markDown');
+
+      spyOn(TestBed.get(OsdService), 'safeToDestroy').and.callFake(() =>
+        of({ 'safe-to-destroy': true })
+      );
 
-    it(`shows 'Perform task' as drop down`, () => {
-      expect(
-        fixture.debugElement.query(By.directive(TableActionsComponent)).query(By.css('button'))
-          .nativeElement.textContent
-      ).toBe('Perform Task');
+      expectOsdServiceMethodCalled(() => component.markLost(), 'markLost');
+      expectOsdServiceMethodCalled(() => component.remove(), 'remove');
+      expectOsdServiceMethodCalled(() => component.destroy(), 'destroy');
     });
   });
 });
index f4992636280fe10de1e3d40f95092bef55de59ee..bbd94075bc7f1ecdf04c1af94de27601a9f55350 100644 (file)
@@ -1,9 +1,11 @@
 import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
 
 import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
+import { Observable } from 'rxjs';
 
 import { OsdService } from '../../../../shared/api/osd.service';
 import { ConfirmationModalComponent } from '../../../../shared/components/confirmation-modal/confirmation-modal.component';
+import { CriticalConfirmationModalComponent } from '../../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { TableComponent } from '../../../../shared/datatable/table/table.component';
 import { CellTemplate } from '../../../../shared/enum/cell-template.enum';
 import { CdTableAction } from '../../../../shared/models/cd-table-action';
@@ -13,6 +15,7 @@ import { Permission } from '../../../../shared/models/permissions';
 import { DimlessBinaryPipe } from '../../../../shared/pipes/dimless-binary.pipe';
 import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
 import { OsdFlagsModalComponent } from '../osd-flags-modal/osd-flags-modal.component';
+import { OsdReweightModalComponent } from '../osd-reweight-modal/osd-reweight-modal.component';
 import { OsdScrubModalComponent } from '../osd-scrub-modal/osd-scrub-modal.component';
 
 @Component({
@@ -25,21 +28,27 @@ export class OsdListComponent implements OnInit {
   statusColor: TemplateRef<any>;
   @ViewChild('osdUsageTpl')
   osdUsageTpl: TemplateRef<any>;
+  @ViewChild('markOsdConfirmationTpl')
+  markOsdConfirmationTpl: TemplateRef<any>;
+  @ViewChild('criticalConfirmationTpl')
+  criticalConfirmationTpl: TemplateRef<any>;
   @ViewChild(TableComponent)
   tableComponent: TableComponent;
+  @ViewChild('reweightBodyTpl')
+  reweightBodyTpl: TemplateRef<any>;
+  @ViewChild('safeToDestroyBodyTpl')
+  safeToDestroyBodyTpl: TemplateRef<any>;
 
   permission: Permission;
   tableActions: CdTableAction[];
   bsModalRef: BsModalRef;
-  osds = [];
   columns: CdTableColumn[];
+
+  osds = [];
   selection = new CdTableSelection();
 
   protected static collectStates(osd) {
-    return [
-      osd['in'] ? 'in' : 'out',
-      osd['up'] ? 'up' : 'out',
-    ];
+    return [osd['in'] ? 'in' : 'out', osd['up'] ? 'up' : 'down'];
   }
 
   constructor(
@@ -49,19 +58,71 @@ export class OsdListComponent implements OnInit {
     private modalService: BsModalService
   ) {
     this.permission = this.authStorageService.getPermissions().osd;
-    const scrubAction: CdTableAction = {
-      permission: 'update',
-      icon: 'fa-stethoscope',
-      click: () => this.scrubAction(false),
-      name: 'Scrub'
-    };
-    const deleteAction: CdTableAction = {
-      permission: 'update',
-      icon: 'fa-cog',
-      click: () => this.scrubAction(true),
-      name: 'Deep Scrub'
-    };
-    this.tableActions = [scrubAction, deleteAction];
+    this.tableActions = [
+      {
+        name: 'Scrub',
+        permission: 'update',
+        icon: 'fa-stethoscope',
+        click: () => this.scrubAction(false),
+        disable: () => !this.hasOsdSelected
+      },
+      {
+        name: 'Deep Scrub',
+        permission: 'update',
+        icon: 'fa-cog',
+        click: () => this.scrubAction(true),
+        disable: () => !this.hasOsdSelected
+      },
+      {
+        name: 'Reweight',
+        permission: 'update',
+        click: () => this.reweight(),
+        disable: () => !this.hasOsdSelected,
+        icon: 'fa-balance-scale'
+      },
+      {
+        name: 'Mark Out',
+        permission: 'update',
+        click: () => this.markOut(),
+        disable: () => this.isNotSelectedOrInState('out'),
+        icon: 'fa-arrow-left'
+      },
+      {
+        name: 'Mark In',
+        permission: 'update',
+        click: () => this.markIn(),
+        disable: () => this.isNotSelectedOrInState('in'),
+        icon: 'fa-arrow-right'
+      },
+      {
+        name: 'Mark Down',
+        permission: 'update',
+        click: () => this.markDown(),
+        disable: () => this.isNotSelectedOrInState('down'),
+        icon: 'fa-arrow-down'
+      },
+      {
+        name: 'Mark lost',
+        permission: 'delete',
+        click: () => this.markLost(),
+        disable: () => this.isNotSelectedOrInState('up'),
+        icon: 'fa-unlink'
+      },
+      {
+        name: 'Remove',
+        permission: 'delete',
+        click: () => this.remove(),
+        disable: () => this.isNotSelectedOrInState('up'),
+        icon: 'fa-remove'
+      },
+      {
+        name: 'Destroy',
+        permission: 'delete',
+        click: () => this.destroy(),
+        disable: () => this.isNotSelectedOrInState('up'),
+        icon: 'fa-eraser'
+      }
+    ];
   }
 
   ngOnInit() {
@@ -87,10 +148,48 @@ export class OsdListComponent implements OnInit {
     ];
   }
 
+  get hasOsdSelected() {
+    if (this.selection.hasSelection) {
+      const osdId = this.selection.first().id;
+      const osd = this.osds.filter((o) => o.id === osdId).pop();
+      return !!osd;
+    }
+    return false;
+  }
+
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
 
+  /**
+   * Returns true if no row is selected or if the selected row is in the given
+   * state. Useful for deactivating the corresponding menu entry.
+   */
+  isNotSelectedOrInState(state: 'in' | 'up' | 'down' | 'out'): boolean {
+    if (!this.hasOsdSelected) {
+      return true;
+    }
+
+    const osdId = this.selection.first().id;
+    const osd = this.osds.filter((o) => o.id === osdId).pop();
+
+    if (!osd) {
+      // `osd` is undefined if the selected OSD has been removed.
+      return true;
+    }
+
+    switch (state) {
+      case 'in':
+        return osd.in === 1;
+      case 'out':
+        return osd.in !== 1;
+      case 'down':
+        return osd.up !== 1;
+      case 'up':
+        return osd.up === 1;
+    }
+  }
+
   getOsdList() {
     this.osdService.getList().subscribe((data: any[]) => {
       this.osds = data;
@@ -105,7 +204,7 @@ export class OsdListComponent implements OnInit {
   }
 
   scrubAction(deep) {
-    if (!this.tableComponent.selection.hasSelection) {
+    if (!this.hasOsdSelected) {
       return;
     }
 
@@ -120,4 +219,82 @@ export class OsdListComponent implements OnInit {
   configureClusterAction() {
     this.bsModalRef = this.modalService.show(OsdFlagsModalComponent, {});
   }
+
+  showConfirmationModal(markAction: string, onSubmit: (id: number) => Observable<any>) {
+    this.bsModalRef = this.modalService.show(ConfirmationModalComponent, {
+      initialState: {
+        titleText: `Mark OSD ${markAction}`,
+        buttonText: `Mark ${markAction}`,
+        bodyTpl: this.markOsdConfirmationTpl,
+        bodyContext: {
+          markActionDescription: markAction
+        },
+        onSubmit: () => {
+          onSubmit
+            .call(this.osdService, this.selection.first().id)
+            .subscribe(() => this.bsModalRef.hide());
+        }
+      }
+    });
+  }
+
+  markOut() {
+    this.showConfirmationModal('out', this.osdService.markOut);
+  }
+
+  markIn() {
+    this.showConfirmationModal('in', this.osdService.markIn);
+  }
+
+  markDown() {
+    this.showConfirmationModal('down', this.osdService.markDown);
+  }
+
+  reweight() {
+    const selectedOsd = this.osds.filter((o) => o.id === this.selection.first().id).pop();
+    this.modalService.show(OsdReweightModalComponent, {
+      initialState: {
+        currentWeight: selectedOsd.weight,
+        osdId: selectedOsd.id
+      }
+    });
+  }
+
+  showCriticalConfirmationModal(
+    actionDescription: string,
+    itemDescription: string,
+    templateItemDescription: string,
+    action: (id: number) => Observable<any>
+  ): void {
+    this.osdService.safeToDestroy(this.selection.first().id).subscribe((result) => {
+      const modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+        initialState: {
+          actionDescription: actionDescription,
+          itemDescription: itemDescription,
+          bodyTemplate: this.criticalConfirmationTpl,
+          bodyContext: {
+            result: result,
+            actionDescription: templateItemDescription
+          },
+          submitAction: () => {
+            action
+              .call(this.osdService, this.selection.first().id)
+              .subscribe(() => modalRef.hide());
+          }
+        }
+      });
+    });
+  }
+
+  markLost() {
+    this.showCriticalConfirmationModal('Mark', 'OSD lost', 'marked lost', this.osdService.markLost);
+  }
+
+  remove() {
+    this.showCriticalConfirmationModal('Remove', 'OSD', 'removed', this.osdService.remove);
+  }
+
+  destroy() {
+    this.showCriticalConfirmationModal('destroy', 'OSD', 'destroyed', this.osdService.destroy);
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html
new file mode 100644 (file)
index 0000000..15924e2
--- /dev/null
@@ -0,0 +1,47 @@
+<cd-modal [modalRef]="bsModalRef">
+  <ng-container class="modal-title"
+                i18n>
+    Reweight OSD
+  </ng-container>
+
+  <ng-container class="modal-content">
+    <form class="form-horizontal"
+          [formGroup]="reweightForm">
+      <div class="modal-body" [ngClass]="{'has-error': weight.errors}">
+        <div class="row">
+          <label for="weight" class="col-sm-2 control-label">Weight</label>
+          <div class="col-sm-10">
+            <input id="weight" class="form-control" type="number"
+                   step="0.1" formControlName="weight" min="0" max="1"
+                   [value]="currentWeight">
+            <span i18n
+                  class="help-block"
+                  *ngIf="weight.errors">
+              <span *ngIf="weight.errors?.required" i18n>
+                This field is required.
+              </span>
+              <span *ngIf="weight.errors?.max || weight.errors?.min" i18n>
+                The value needs to be between 0 and 1.
+              </span>
+            </span>
+          </div>
+        </div>
+      </div>
+
+      <div class="modal-footer">
+        <cd-submit-button (submitAction)="reweight()"
+                          [form]="reweightForm"
+                          [disabled]="reweightForm.invalid"
+                          i18n>
+          Reweight
+        </cd-submit-button>
+
+        <button class="btn btn-link btn-sm"
+                (click)="bsModalRef.hide()"
+                i18n>
+          Cancel
+        </button>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..93d1934
--- /dev/null
@@ -0,0 +1,45 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap/modal';
+import { of } from 'rxjs';
+
+import { configureTestBed } from '../../../../../testing/unit-test-helper';
+import { OsdService } from '../../../../shared/api/osd.service';
+import { ModalComponent } from '../../../../shared/components/modal/modal.component';
+import { SubmitButtonComponent } from '../../../../shared/components/submit-button/submit-button.component';
+import { CdFormBuilder } from '../../../../shared/forms/cd-form-builder';
+import { OsdReweightModalComponent } from './osd-reweight-modal.component';
+
+describe('OsdReweightModalComponent', () => {
+  let component: OsdReweightModalComponent;
+  let fixture: ComponentFixture<OsdReweightModalComponent>;
+
+  configureTestBed({
+    imports: [ReactiveFormsModule, HttpClientTestingModule],
+    declarations: [OsdReweightModalComponent, ModalComponent, SubmitButtonComponent],
+    providers: [OsdService, BsModalRef, CdFormBuilder]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(OsdReweightModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should call OsdService::reweight() on submit', () => {
+    component.osdId = 1;
+    component.reweightForm.get('weight').setValue(0.5);
+
+    const osdServiceSpy = spyOn(TestBed.get(OsdService), 'reweight').and.callFake(() => of(true));
+    component.reweight();
+
+    expect(osdServiceSpy.calls.count()).toBe(1);
+    expect(osdServiceSpy.calls.first().args).toEqual([1, 0.5]);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.ts
new file mode 100644 (file)
index 0000000..4f884d1
--- /dev/null
@@ -0,0 +1,45 @@
+import { Component, OnInit } from '@angular/core';
+import { Validators } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { OsdService } from '../../../../shared/api/osd.service';
+import { CdFormBuilder } from '../../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
+
+@Component({
+  selector: 'cd-osd-reweight-modal',
+  templateUrl: './osd-reweight-modal.component.html',
+  styleUrls: ['./osd-reweight-modal.component.scss']
+})
+export class OsdReweightModalComponent implements OnInit {
+  currentWeight = 1;
+  osdId: number;
+  reweightForm: CdFormGroup;
+
+  constructor(
+    public bsModalRef: BsModalRef,
+    private osdService: OsdService,
+    private fb: CdFormBuilder
+  ) {}
+
+  get weight() {
+    return this.reweightForm.get('weight');
+  }
+
+  ngOnInit() {
+    this.reweightForm = this.fb.group({
+      weight: this.fb.control(this.currentWeight, [
+        Validators.required,
+        Validators.max(1),
+        Validators.min(0)
+      ])
+    });
+  }
+
+  reweight() {
+    this.osdService
+      .reweight(this.osdId, this.reweightForm.value.weight)
+      .subscribe(() => this.bsModalRef.hide());
+  }
+}
index 6e1c2ea976e804501f31b174019a7243ee5091a4..cad9b85a1e147d7f52893bbb71c3e0d47e10e6b2 100644 (file)
@@ -3,6 +3,7 @@ import { Component, OnInit, ViewChild } from '@angular/core';
 import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
 
 import { PoolService } from '../../../shared/api/pool.service';
+import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { TableComponent } from '../../../shared/datatable/table/table.component';
 import { CellTemplate } from '../../../shared/enum/cell-template.enum';
 import { ViewCacheStatus } from '../../../shared/enum/view-cache-status.enum';
@@ -16,7 +17,6 @@ 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 { Pool } from '../pool';
-import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 
 @Component({
   selector: 'cd-pool-list',
index 5610875f69d5453c821850834a24e2661272a41d..08c87c3e4369f91bfa80ddb7b1c14bd1fb72da30 100644 (file)
@@ -62,4 +62,53 @@ describe('OsdService', () => {
     expect(req.request.method).toBe('PUT');
     expect(req.request.body).toEqual({ flags: ['foo'] });
   });
+
+  it('should mark the OSD out', () => {
+    service.markOut(1).subscribe();
+    const req = httpTesting.expectOne('api/osd/1/mark_out');
+    expect(req.request.method).toBe('POST');
+  });
+
+  it('should mark the OSD in', () => {
+    service.markIn(1).subscribe();
+    const req = httpTesting.expectOne('api/osd/1/mark_in');
+    expect(req.request.method).toBe('POST');
+  });
+
+  it('should mark the OSD down', () => {
+    service.markDown(1).subscribe();
+    const req = httpTesting.expectOne('api/osd/1/mark_down');
+    expect(req.request.method).toBe('POST');
+  });
+
+  it('should reweight an OSD', () => {
+    service.reweight(1, 0.5).subscribe();
+    const req = httpTesting.expectOne('api/osd/1/reweight');
+    expect(req.request.method).toBe('POST');
+    expect(req.request.body).toEqual({ weight: 0.5 });
+  });
+
+  it('should mark an OSD lost', () => {
+    service.markLost(1).subscribe();
+    const req = httpTesting.expectOne('api/osd/1/mark_lost');
+    expect(req.request.method).toBe('POST');
+  });
+
+  it('should remove an OSD', () => {
+    service.remove(1).subscribe();
+    const req = httpTesting.expectOne('api/osd/1/remove');
+    expect(req.request.method).toBe('POST');
+  });
+
+  it('should destroy an OSD', () => {
+    service.destroy(1).subscribe();
+    const req = httpTesting.expectOne('api/osd/1/destroy');
+    expect(req.request.method).toBe('POST');
+  });
+
+  it('should return if it is safe to destroy an OSD', () => {
+    service.safeToDestroy(1).subscribe();
+    const req = httpTesting.expectOne('api/osd/1/safe_to_destroy');
+    expect(req.request.method).toBe('GET');
+  });
 });
index 4035de5ad702045e5203fd9e250062985bc0fa8e..03e056d17514b7a5cd17b484bf9594300a4de9e6 100644 (file)
@@ -30,4 +30,40 @@ export class OsdService {
   updateFlags(flags: string[]) {
     return this.http.put(`${this.path}/flags`, { flags: flags });
   }
+
+  markOut(id: number) {
+    return this.http.post(`${this.path}/${id}/mark_out`, null);
+  }
+
+  markIn(id: number) {
+    return this.http.post(`${this.path}/${id}/mark_in`, null);
+  }
+
+  markDown(id: number) {
+    return this.http.post(`${this.path}/${id}/mark_down`, null);
+  }
+
+  reweight(id: number, weight: number) {
+    return this.http.post(`${this.path}/${id}/reweight`, { weight: weight });
+  }
+
+  markLost(id: number) {
+    return this.http.post(`${this.path}/${id}/mark_lost`, null);
+  }
+
+  remove(id: number) {
+    return this.http.post(`${this.path}/${id}/remove`, null);
+  }
+
+  destroy(id: number) {
+    return this.http.post(`${this.path}/${id}/destroy`, null);
+  }
+
+  safeToDestroy(id: number) {
+    interface SafeToDestroyResponse {
+      'safe-to-destroy': boolean;
+      message?: string;
+    }
+    return this.http.get<SafeToDestroyResponse>(`${this.path}/${id}/safe_to_destroy`);
+  }
 }
index 9ad9d4895ec7e6aae930929a92f3354391b53ed6..8b437dd8286342ddc6980a41e23784098e4640bf 100644 (file)
@@ -72,10 +72,6 @@ import { WarningPanelComponent } from './warning-panel/warning-panel.component';
     WarningPanelComponent,
     GrafanaComponent
   ],
-  entryComponents: [
-    ModalComponent,
-    CriticalConfirmationModalComponent,
-    ConfirmationModalComponent
-  ]
+  entryComponents: [ModalComponent, CriticalConfirmationModalComponent, ConfirmationModalComponent]
 })
 export class ComponentsModule {}
index e3457f96cfe5fab4d97bf2e0ab2dda7e036e3954..b51faa0694850774eab1991d89ace7954f2221a7 100644 (file)
@@ -13,7 +13,7 @@
         <div class="button-group text-right">
           <cd-submit-button i18n
                             [form]="confirmationForm"
-                            (submitAction)="submit()">
+                            (submitAction)="onSubmit(confirmationForm.value)">
             {{ buttonText }}
           </cd-submit-button>
           <button i18n
index adf685c0d699f1ebe573bb165e9ab7fa67d09b00..c108cb40f912150d03a90751e75fea14107816e6 100644 (file)
@@ -24,13 +24,8 @@ export class ConfirmationModalComponent implements OnInit {
   }
 
   ngOnInit() {
-    this.bodyContext = {
-      $implicit: this.bodyData
-    };
-  }
-
-  submit() {
-    this.onSubmit();
+    this.bodyContext = this.bodyContext || {};
+    this.bodyContext['$implicit'] = this.bodyData;
   }
 
   cancel() {
index 0189fce78e99c7e51c57c74cd3bd6264150534a6..d749a233c590807bf511e6a9233676cc3b15153a 100644 (file)
@@ -91,7 +91,7 @@ class MockComponent {
   }
 }
 
-describe('DeletionModalComponent', () => {
+describe('CriticalConfirmationModalComponent', () => {
   let mockComponent: MockComponent;
   let component: CriticalConfirmationModalComponent;
   let mockFixture: ComponentFixture<MockComponent>;
index 27d39f3b77ac4efde5ceb7dac0e69dc370fce828..a721bcae5fcd99d856bf867f4255179d847fee99 100644 (file)
@@ -16,7 +16,7 @@ export class CriticalConfirmationModalComponent implements OnInit {
   @ViewChild(SubmitButtonComponent)
   submitButton: SubmitButtonComponent;
   bodyTemplate: TemplateRef<any>;
-  bodyContext: any;
+  bodyContext: object;
   submitActionObservable: () => Observable<any>;
   submitAction: Function;
   deletionForm: CdFormGroup;
index 8bb2a449c6f52a5ca43b7e75c8a83c701cdcf521..c5750e7276b39e3a4e9dbefd7e0b6d986f4ef793 100644 (file)
@@ -1,6 +1,6 @@
 <button [type]="type"
         class="btn btn-sm btn-primary tc_submitButton"
-        [disabled]="loading"
+        [disabled]="loading || disabled"
         (click)="submit($event)">
   <ng-content></ng-content>
   <span *ngIf="loading">
index e11acd20ab894841b35b184ff934f43a18fdf29f..1b98d67effe40dd084c0d6e93f4f04f5e30f43ea 100644 (file)
@@ -33,6 +33,8 @@ export class SubmitButtonComponent implements OnInit {
   type = 'submit';
   @Output()
   submitAction = new EventEmitter();
+  @Input()
+  disabled = false;
 
   loading = false;
 
index 39d7dcb3e5e8220b777710683e3666dc7fce43e4..91dffb35c098fd30f38e815d29ae498bc0bdc00c 100644 (file)
@@ -20,6 +20,7 @@
   <ul *dropdownMenu class="dropdown-menu" role="menu">
     <ng-container *ngFor="let action of dropDownActions">
       <li role="menuitem"
+          class="{{ toClassName(action['name']) }}"
           [ngClass]="{'disabled': disableSelectionAction(action)}">
         <a class="dropdown-item"
            (click)="useClickAction(action)"
index fef1b2fcd83681227244b9c3a5ff4699f7a72a8d..35d8ba4933fa75e97d9dc4e8ec8138eda5518907 100644 (file)
@@ -278,4 +278,10 @@ describe('TableActionsComponent', () => {
       expect(component.getCurrentButton()).toBeFalsy();
     });
   });
+
+  it('should convert any name to a proper CSS class', () => {
+    expect(component.toClassName('Create')).toBe('create');
+    expect(component.toClassName('Mark x down')).toBe('mark-x-down');
+    expect(component.toClassName('?Su*per!')).toBe('super');
+  });
 });
index a6fb9fe5355df57888f43dd3d88dd5c8d328c076..b59592195c1bb07a5e02ef35d003ab100165e336 100644 (file)
@@ -35,6 +35,13 @@ export class TableActionsComponent implements OnInit {
     this.updateDropDownActions();
   }
 
+  toClassName(name: string): string {
+    return name
+      .replace(/ /g, '-')
+      .replace(/[^a-z-]/gi, '')
+      .toLowerCase();
+  }
+
   /**
    * Removes all actions from 'tableActions' that need a permission the user doesn't have.
    */
index fbf15dc87f68da92adc37d2e2e033059229b9153..242c666652398d9121ff55e8f37e6f8b47ac1e33 100644 (file)
@@ -166,9 +166,8 @@ describe('CdValidators', () => {
 
     beforeEach(() => {
       form = new FormGroup({
-        x: new FormControl()
+        x: new FormControl(null, CdValidators.uuid())
       });
-      form.controls['x'].setValidators(CdValidators.uuid());
     });
 
     it('should accept empty value', () => {
@@ -194,27 +193,30 @@ describe('CdValidators', () => {
 
     it('should error on uuid containing too many blocks', () => {
       const x = form.get('x');
+      x.markAsDirty();
       x.setValue('e33bbcb6-fcc3-40b1-ae81-3f81706a35d5-23d3');
       expect(x.valid).toBeFalsy();
-      expect(x.hasError('pattern')).toBeTruthy();
+      expect(x.hasError('invalidUuid')).toBeTruthy();
     });
 
     it('should error on uuid containing too many chars in block', () => {
       const x = form.get('x');
+      x.markAsDirty();
       x.setValue('aae33bbcb6-fcc3-40b1-ae81-3f81706a35d5');
       expect(x.valid).toBeFalsy();
-      expect(x.hasError('pattern')).toBeTruthy();
+      expect(x.hasError('invalidUuid')).toBeTruthy();
     });
 
     it('should error on uuid containing invalid char', () => {
       const x = form.get('x');
+      x.markAsDirty();
       x.setValue('x33bbcb6-fcc3-40b1-ae81-3f81706a35d5');
       expect(x.valid).toBeFalsy();
-      expect(x.hasError('pattern')).toBeTruthy();
+      expect(x.hasError('invalidUuid')).toBeTruthy();
 
       x.setValue('$33bbcb6-fcc3-40b1-ae81-3f81706a35d5');
       expect(x.valid).toBeFalsy();
-      expect(x.hasError('pattern')).toBeTruthy();
+      expect(x.hasError('invalidUuid')).toBeTruthy();
     });
   });
 
index 0d4824309305cc59d96299a1c68548846ed610f2..620c4b65eb620d8d16e2b96f7ef1e87c4ec27aee 100644 (file)
@@ -52,16 +52,6 @@ export class CdValidators {
     }
   }
 
-  /**
-   * Validator function in order to validate uuids.
-   * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
-   * if the validation failed, otherwise `null`.
-   */
-  static uuid(): ValidatorFn {
-    const uuidRgx = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
-    return Validators.pattern(uuidRgx);
-  }
-
   /**
    * Validator function in order to validate numbers.
    * @returns {ValidatorFn} A validator function that returns an error map containing `pattern`
@@ -238,4 +228,24 @@ export class CdValidators {
       );
     };
   }
+
+  /**
+   * Validator function for UUIDs.
+   * @param required - Defines if it is mandatory to fill in the UUID
+   * @return Validator function that returns an error object containing `invalidUuid` if the
+   * validation failed, `null` otherwise.
+   */
+  static uuid(required = false): ValidatorFn {
+    const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+    return (control: AbstractControl): { [key: string]: any } | null => {
+      if (control.pristine && control.untouched) {
+        return null;
+      } else if (!required && !control.value) {
+        return null;
+      } else if (uuidRe.test(control.value)) {
+        return null;
+      }
+      return { invalidUuid: 'This is not a valid UUID' };
+    };
+  }
 }
index ad9f4c14387eb88e22c83db4eb917058b80bb6b6..e266a66014cd4cc506f60736db8215bc8ac1bbcc 100644 (file)
@@ -9,8 +9,7 @@ export class CdTableAction {
   // This is the function that will be triggered on a click event if defined
   click?: Function;
 
-  // Only change permissions are allowed
-  permission: 'create' | 'update' | 'delete';
+  permission: 'create' | 'update' | 'delete' | 'read';
 
   // The name of the action
   name: string;
@@ -23,9 +22,13 @@ export class CdTableAction {
   // if one selection is made and no task is running on the selected item.
   disable?: (_: CdTableSelection) => boolean;
 
-  // You can define the condition to disable the action.
-  // By default all 'create' actions can be the action button if no or multiple items are selected
-  // By default all 'update' and 'delete' actions can be the action button if one item is selected
+  /**
+   * Defines if the button can become 'primary' (displayed as button and not
+   * 'hidden' in the menu). Only one button can be primary at a time. By
+   * default all 'create' actions can be the action button if no or multiple
+   * items are selected. Also, all 'update' and 'delete' actions can be the
+   * action button by default, provided only one item is selected.
+   */
   canBePrimary?: (_: CdTableSelection) => boolean;
 
   // In some rare cases you want to hide a action that can be used by the user for example
index 74a7feb830fa5326abbd5e054263bcd5de16595f..ab747bf6c2fb8a6714738d709de5eb11562e4312 100644 (file)
@@ -1,7 +1,7 @@
 @import '../defaults';
 
 ::ng-deep .popover-content {
-  padding: 0px 0px;
+  padding: 0.5em;
   height: auto;
   max-height: 70vh;
   overflow-x: hidden;
index e83975126aba4dbc747614a7d310bd04e54806a7..8e1cc2b461f13cc107ac5a110a7377230731ae35 100644 (file)
@@ -63,12 +63,14 @@ export class PermissionHelper {
     singleExecuting?: any; // uses 'single' if not defined
     multiple?: any; // uses 'empty' if not defined
   }) {
-    this.testScenario( // 'multiple selections'
+    this.testScenario(
+      // 'multiple selections'
       [{}, {}],
       fn,
       _.isUndefined(multiple) ? empty : multiple
     );
-    this.testScenario( // 'select executing item'
+    this.testScenario(
+      // 'select executing item'
       [{ cdExecuting: 'someAction' }],
       fn,
       _.isUndefined(singleExecuting) ? single : singleExecuting
@@ -77,11 +79,7 @@ export class PermissionHelper {
     this.testScenario([], fn, empty); // 'no selection'
   }
 
-  private testScenario(
-    selection: object[],
-    fn: () => any,
-    expected: any
-  ) {
+  private testScenario(selection: object[], fn: () => any, expected: any) {
     this.setSelection(selection);
     expect(fn()).toBe(expected);
   }