From 7a0b4e794f3c478d178af57fa0e4da4bfbb0e63b Mon Sep 17 00:00:00 2001 From: Patrick Nawracay Date: Fri, 5 Oct 2018 15:35:12 +0200 Subject: [PATCH] mgr/dashboard: Add support for managing individual OSD settings/characteristics in the frontend Fixes: http://tracker.ceph.com/issues/35448 Signed-off-by: Patrick Nawracay --- .../frontend/src/app/app-routing.module.ts | 10 +- .../src/app/ceph/cluster/cluster.module.ts | 17 +- .../configuration-form.component.html | 5 + .../osd/osd-list/osd-list.component.html | 28 ++- .../osd/osd-list/osd-list.component.spec.ts | 151 +++++++++++- .../osd/osd-list/osd-list.component.ts | 215 ++++++++++++++++-- .../osd-reweight-modal.component.html | 47 ++++ .../osd-reweight-modal.component.scss | 0 .../osd-reweight-modal.component.spec.ts | 45 ++++ .../osd-reweight-modal.component.ts | 45 ++++ .../pool/pool-list/pool-list.component.ts | 2 +- .../src/app/shared/api/osd.service.spec.ts | 49 ++++ .../src/app/shared/api/osd.service.ts | 36 +++ .../shared/components/components.module.ts | 6 +- .../confirmation-modal.component.html | 2 +- .../confirmation-modal.component.ts | 9 +- ...critical-confirmation-modal.component.scss | 1 + ...tical-confirmation-modal.component.spec.ts | 2 +- .../critical-confirmation-modal.component.ts | 2 +- .../submit-button.component.html | 2 +- .../submit-button/submit-button.component.ts | 2 + .../table-actions.component.html | 1 + .../table-actions.component.spec.ts | 6 + .../table-actions/table-actions.component.ts | 7 + .../app/shared/forms/cd-validators.spec.ts | 14 +- .../src/app/shared/forms/cd-validators.ts | 30 ++- .../src/app/shared/models/cd-table-action.ts | 13 +- .../frontend/src/styles/popover.scss | 2 +- .../frontend/src/testing/unit-test-helper.ts | 12 +- 29 files changed, 677 insertions(+), 84 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 1807c98ab9b..80807c35d71 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -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', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index 748568eb768..2d60ff99e64 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -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 {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html index dfedb8d0f83..00821bd2400 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html @@ -136,6 +136,11 @@ i18n> {{ patternHelpText }} + + {{ patternHelpText }} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html index 61b146e6b56..38d31c0c03a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html @@ -7,10 +7,9 @@ selectionType="single" (updateSelection)="updateSelection($event)" [updateSelectionOnRefresh]="'never'"> -
+
@@ -53,3 +52,28 @@ + + + + + + + OSD {{ selection.first().id }} will be marked + {{ markActionDescription }} if you proceed. + + + +
+ + {{ safeToDestroyResult.message }} + +
+ OSD {{ selection.first().id }} will be + {{ actionDescription }} if you proceed. +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts index a8c293b97a9..2814989c12d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts @@ -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; + 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'); }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts index f4992636280..bbd94075bc7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts @@ -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; @ViewChild('osdUsageTpl') osdUsageTpl: TemplateRef; + @ViewChild('markOsdConfirmationTpl') + markOsdConfirmationTpl: TemplateRef; + @ViewChild('criticalConfirmationTpl') + criticalConfirmationTpl: TemplateRef; @ViewChild(TableComponent) tableComponent: TableComponent; + @ViewChild('reweightBodyTpl') + reweightBodyTpl: TemplateRef; + @ViewChild('safeToDestroyBodyTpl') + safeToDestroyBodyTpl: TemplateRef; 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) { + 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 + ): 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 index 00000000000..15924e232a7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html @@ -0,0 +1,47 @@ + + + Reweight OSD + + + +
+ + + +
+
+
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 index 00000000000..e69de29bb2d 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 index 00000000000..93d19341c9a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.spec.ts @@ -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; + + 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 index 00000000000..4f884d1bdef --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.ts @@ -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()); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts index 6e1c2ea976e..cad9b85a1e1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts @@ -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', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts index 5610875f69d..08c87c3e436 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts @@ -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'); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts index 4035de5ad70..03e056d1751 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts @@ -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(`${this.path}/${id}/safe_to_destroy`); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 9ad9d4895ec..8b437dd8286 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -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 {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html index e3457f96cfe..b51faa06948 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html @@ -13,7 +13,7 @@
+ (submitAction)="onSubmit(confirmationForm.value)"> {{ buttonText }}