From 4c43214fc57d30750426184bc86f042aee826d18 Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Mon, 11 Nov 2019 16:30:50 +0100 Subject: [PATCH] mgr/dashboard: Add support for blinking enclosure LEDs Fixes: https://tracker.ceph.com/issues/42609 Signed-off-by: Volker Theile --- .../mgr/dashboard/controllers/orchestrator.py | 36 ++++++++- .../host-details.component.spec.ts | 4 +- .../inventory-devices.component.html | 8 +- .../inventory-devices.component.spec.ts | 4 +- .../inventory-devices.component.ts | 76 ++++++++++++++++++- .../inventory/inventory.component.spec.ts | 9 ++- ...devices-selection-groups.component.spec.ts | 5 +- ...-devices-selection-modal.component.spec.ts | 11 ++- .../osd/osd-form/osd-form.component.spec.ts | 4 +- .../shared/api/orchestrator.service.spec.ts | 13 ++-- .../app/shared/api/orchestrator.service.ts | 21 +++-- .../form-modal/form-modal.component.html | 24 ++++++ .../form-modal/form-modal.component.ts | 8 +- .../shared/services/task-message.service.ts | 9 +++ .../mgr/dashboard/services/orchestrator.py | 7 +- 15 files changed, 212 insertions(+), 27 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/orchestrator.py b/src/pybind/mgr/dashboard/controllers/orchestrator.py index f38ac90a87e07..660203340d865 100644 --- a/src/pybind/mgr/dashboard/controllers/orchestrator.py +++ b/src/pybind/mgr/dashboard/controllers/orchestrator.py @@ -1,16 +1,23 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +import time + import cherrypy -from ceph.deployment.drive_group import DriveGroupSpec, DriveGroupValidationError -from . import ApiController, Endpoint, ReadPermission +try: + from ceph.deployment.drive_group import DriveGroupSpec, DriveGroupValidationError +except ImportError: + pass + +from . import ApiController, Endpoint, ReadPermission, UpdatePermission from . import RESTController, Task from .. import mgr from ..exceptions import DashboardException from ..security import Scope +from ..services.exception import handle_orchestrator_error from ..services.orchestrator import OrchClient -from ..tools import wraps +from ..tools import TaskManager, wraps def get_device_osd_map(): @@ -69,6 +76,29 @@ class Orchestrator(RESTController): def status(self): return OrchClient.instance().status() + @Endpoint(method='POST') + @UpdatePermission + @raise_if_no_orchestrator + @handle_orchestrator_error('osd') + @orchestrator_task('identify_device', ['{hostname}', '{device}']) + def identify_device(self, hostname, device, duration): + # type: (str, str, int) -> None + """ + Identify a device by switching on the device light for N seconds. + :param hostname: The hostname of the device to process. + :param device: The device identifier to process, e.g. ``ABC1234DEF567-1R1234_ABC8DE0Q``. + :param duration: The duration in seconds how long the LED should flash. + """ + orch = OrchClient.instance() + TaskManager.current_task().set_progress(0) + orch.blink_device_light(hostname, device, 'ident', True) + for i in range(int(duration)): + percentage = int(round(i / float(duration) * 100)) + TaskManager.current_task().set_progress(percentage) + time.sleep(1) + orch.blink_device_light(hostname, device, 'ident', False) + TaskManager.current_task().set_progress(100) + @ApiController('/orchestrator/inventory', Scope.HOSTS) class OrchestratorInventory(RESTController): diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts index 224f4c5f3f69d..50759e0a7b479 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts @@ -5,6 +5,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation'; import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; import { TabsModule } from 'ngx-bootstrap/tabs'; +import { ToastrModule } from 'ngx-toastr'; import { of } from 'rxjs'; import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper'; @@ -31,7 +32,8 @@ describe('HostDetailsComponent', () => { CephModule, CoreModule, CephSharedModule, - SharedModule + SharedModule, + ToastrModule.forRoot() ], declarations: [], providers: [i18nProviders] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html index 208bb0d34f19f..197f6049a1eb3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html @@ -5,7 +5,13 @@ [selectionType]="selectionType" columnMode="flex" [autoReload]="false" - [searchField]="false"> + [searchField]="false" + (updateSelection)="updateSelection($event)"> + +
{ ]; configureTestBed({ - imports: [FormsModule, SharedModule], + imports: [FormsModule, HttpClientTestingModule, SharedModule, ToastrModule.forRoot()], providers: [i18nProviders], declarations: [InventoryDevicesComponent] }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts index 45f5dd7b2c22d..ab198492373f0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts @@ -3,11 +3,20 @@ import { I18n } from '@ngx-translate/i18n-polyfill'; import { getterForProp } from '@swimlane/ngx-datatable/release/utils'; import * as _ from 'lodash'; +import { BsModalService } from 'ngx-bootstrap/modal'; +import { OrchestratorService } from '../../../../shared/api/orchestrator.service'; +import { FormModalComponent } from '../../../../shared/components/form-modal/form-modal.component'; import { CellTemplate } from '../../../../shared/enum/cell-template.enum'; import { Icons } from '../../../../shared/enum/icons.enum'; +import { NotificationType } from '../../../../shared/enum/notification-type.enum'; +import { CdTableAction } from '../../../../shared/models/cd-table-action'; import { CdTableColumn } from '../../../../shared/models/cd-table-column'; +import { CdTableSelection } from '../../../../shared/models/cd-table-selection'; +import { Permission } from '../../../../shared/models/permissions'; import { DimlessBinaryPipe } from '../../../../shared/pipes/dimless-binary.pipe'; +import { AuthStorageService } from '../../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../../shared/services/notification.service'; import { InventoryDeviceFilter } from './inventory-device-filter.interface'; import { InventoryDeviceFiltersChangeEvent } from './inventory-device-filters-change-event.interface'; import { InventoryDevice } from './inventory-device.model'; @@ -45,10 +54,32 @@ export class InventoryDevicesComponent implements OnInit, OnChanges { icons = Icons; columns: Array = []; filters: InventoryDeviceFilter[] = []; + selection: CdTableSelection = new CdTableSelection(); + permission: Permission; + tableActions: CdTableAction[]; - constructor(private dimlessBinary: DimlessBinaryPipe, private i18n: I18n) {} + constructor( + private authStorageService: AuthStorageService, + private dimlessBinary: DimlessBinaryPipe, + private i18n: I18n, + private modalService: BsModalService, + private notificationService: NotificationService, + private orchService: OrchestratorService + ) {} ngOnInit() { + this.permission = this.authStorageService.getPermissions().osd; + this.tableActions = [ + { + permission: 'update', + icon: Icons.show, + click: () => this.identifyDevice(), + name: this.i18n('Identify'), + disable: () => !this.selection.hasSingleSelection, + canBePrimary: (selection: CdTableSelection) => !selection.hasSingleSelection, + visible: () => _.isString(this.selectionType) + } + ]; const columns = [ { name: this.i18n('Hostname'), @@ -197,4 +228,47 @@ export class InventoryDevicesComponent implements OnInit, OnChanges { filterOutDevices: this.filterOutDevices }); } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + identifyDevice() { + const selected = this.selection.first(); + const hostname = selected.hostname; + const device = selected.path || selected.device_id; + this.modalService.show(FormModalComponent, { + initialState: { + titleText: this.i18n(`Identify device {{device}}`, { device }), + message: this.i18n('Please enter the duration how long to blink the LED.'), + fields: [ + { + type: 'select', + name: 'duration', + value: 300, + required: true, + options: [ + { text: this.i18n('1 minute'), value: 60 }, + { text: this.i18n('2 minutes'), value: 120 }, + { text: this.i18n('5 minutes'), value: 300 }, + { text: this.i18n('10 minutes'), value: 600 }, + { text: this.i18n('15 minutes'), value: 900 } + ] + } + ], + submitButtonText: this.i18n('Execute'), + onSubmit: (values) => { + this.orchService.identifyDevice(hostname, device, values.duration).subscribe(() => { + this.notificationService.show( + NotificationType.success, + this.i18n(`Identifying '{{device}}' started on host '{{hostname}}'`, { + hostname, + device + }) + ); + }); + } + } + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts index f4455fbb8abfe..10a930f5b58c0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; +import { ToastrModule } from 'ngx-toastr'; import { of } from 'rxjs'; import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; @@ -17,7 +18,13 @@ describe('InventoryComponent', () => { let orchService: OrchestratorService; configureTestBed({ - imports: [FormsModule, SharedModule, HttpClientTestingModule, RouterTestingModule], + imports: [ + FormsModule, + SharedModule, + HttpClientTestingModule, + RouterTestingModule, + ToastrModule.forRoot() + ], providers: [i18nProviders], declarations: [InventoryComponent, InventoryDevicesComponent] }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts index f15f3a0382d1e..bed5e0b0b1912 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts @@ -1,6 +1,9 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; +import { ToastrModule } from 'ngx-toastr'; + import { configureTestBed, FixtureHelper, @@ -46,7 +49,7 @@ describe('OsdDevicesSelectionGroupsComponent', () => { }; configureTestBed({ - imports: [FormsModule, SharedModule], + imports: [FormsModule, HttpClientTestingModule, SharedModule, ToastrModule.forRoot()], providers: [i18nProviders], declarations: [OsdDevicesSelectionGroupsComponent, InventoryDevicesComponent] }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts index d58da46fcd574..0e08b85c5c87f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts @@ -1,8 +1,10 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { BsModalRef } from 'ngx-bootstrap/modal'; +import { ToastrModule } from 'ngx-toastr'; import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper'; import { SharedModule } from '../../../../shared/shared.module'; @@ -41,7 +43,14 @@ describe('OsdDevicesSelectionModalComponent', () => { }; configureTestBed({ - imports: [FormsModule, SharedModule, ReactiveFormsModule, RouterTestingModule], + imports: [ + FormsModule, + HttpClientTestingModule, + SharedModule, + ReactiveFormsModule, + RouterTestingModule, + ToastrModule.forRoot() + ], providers: [BsModalRef, i18nProviders], declarations: [OsdDevicesSelectionModalComponent, InventoryDevicesComponent] }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts index ee2648ac900fd..c882534520c6a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; +import { ToastrModule } from 'ngx-toastr'; import { BehaviorSubject, of } from 'rxjs'; import { @@ -98,7 +99,8 @@ describe('OsdFormComponent', () => { FormsModule, SharedModule, RouterTestingModule, - ReactiveFormsModule + ReactiveFormsModule, + ToastrModule.forRoot() ], providers: [i18nProviders], declarations: [OsdFormComponent, OsdDevicesSelectionGroupsComponent, InventoryDevicesComponent] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts index f7d81d3f68ff4..94b0ed8e28f18 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts @@ -7,6 +7,7 @@ import { OrchestratorService } from './orchestrator.service'; describe('OrchestratorService', () => { let service: OrchestratorService; let httpTesting: HttpTestingController; + const apiPath = 'api/orchestrator'; configureTestBed({ providers: [OrchestratorService, i18nProviders], @@ -28,33 +29,33 @@ describe('OrchestratorService', () => { it('should call status', () => { service.status().subscribe(); - const req = httpTesting.expectOne(service.statusURL); + const req = httpTesting.expectOne(`${apiPath}/status`); expect(req.request.method).toBe('GET'); }); it('should call inventoryList', () => { service.inventoryList().subscribe(); - const req = httpTesting.expectOne(service.inventoryURL); + const req = httpTesting.expectOne(`${apiPath}/inventory`); expect(req.request.method).toBe('GET'); }); it('should call inventoryList with a host', () => { const host = 'host0'; service.inventoryList(host).subscribe(); - const req = httpTesting.expectOne(`${service.inventoryURL}?hostname=${host}`); + const req = httpTesting.expectOne(`${apiPath}/inventory?hostname=${host}`); expect(req.request.method).toBe('GET'); }); it('should call serviceList', () => { service.serviceList().subscribe(); - const req = httpTesting.expectOne(service.serviceURL); + const req = httpTesting.expectOne(`${apiPath}/service`); expect(req.request.method).toBe('GET'); }); it('should call serviceList with a host', () => { const host = 'host0'; service.serviceList(host).subscribe(); - const req = httpTesting.expectOne(`${service.serviceURL}?hostname=${host}`); + const req = httpTesting.expectOne(`${apiPath}/service?hostname=${host}`); expect(req.request.method).toBe('GET'); }); @@ -65,7 +66,7 @@ describe('OrchestratorService', () => { } }; service.osdCreate(data['drive_group']).subscribe(); - const req = httpTesting.expectOne(service.osdURL); + const req = httpTesting.expectOne(`${apiPath}/osd`); expect(req.request.method).toBe('POST'); expect(req.request.body).toEqual(data); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts index dbe70addea350..0b0cec2bf083d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts @@ -13,20 +13,25 @@ import { ApiModule } from './api.module'; providedIn: ApiModule }) export class OrchestratorService { - statusURL = 'api/orchestrator/status'; - inventoryURL = 'api/orchestrator/inventory'; - serviceURL = 'api/orchestrator/service'; - osdURL = 'api/orchestrator/osd'; + private url = 'api/orchestrator'; constructor(private http: HttpClient) {} status() { - return this.http.get(this.statusURL); + return this.http.get(`${this.url}/status`); + } + + identifyDevice(hostname: string, device: string, duration: number) { + return this.http.post(`${this.url}/identify_device`, { + hostname, + device, + duration + }); } inventoryList(hostname?: string): Observable { const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {}; - return this.http.get(this.inventoryURL, options); + return this.http.get(`${this.url}/inventory`, options); } inventoryDeviceList(hostname?: string): Observable { @@ -46,13 +51,13 @@ export class OrchestratorService { serviceList(hostname?: string) { const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {}; - return this.http.get(this.serviceURL, options); + return this.http.get(`${this.url}/service`, options); } osdCreate(driveGroup: {}) { const request = { drive_group: driveGroup }; - return this.http.post(this.osdURL, request, { observe: 'response' }); + return this.http.post(`${this.url}/osd`, request, { observe: 'response' }); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html index d1931943dc7ce..35fedeb5f6d0a 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html @@ -28,6 +28,30 @@ i18n>This field is required.
+ + +
+ + This field is required. +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts index a4da08b1160ca..0572cd02ed583 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts @@ -7,11 +7,17 @@ import { BsModalRef } from 'ngx-bootstrap/modal'; import { CdFormBuilder } from '../../forms/cd-form-builder'; interface CdFormFieldConfig { - type: 'textInput'; + type: 'inputText' | 'select'; name: string; label?: string; value?: any; required?: boolean; + // --- select --- + placeholder?: string; + options?: Array<{ + text: string; + value: any; + }>; } @Component({ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index 86a0def82f2ea..e5ef683774250 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -393,6 +393,15 @@ export class TaskMessageService { this.commonOperations.update, this.grafana.update_dashboards, () => ({}) + ), + // Orchestrator tasks + 'orchestrator/identify_device': this.newTaskMessage( + new TaskMessageOperation( + this.i18n('Identifying'), + this.i18n('identify'), + this.i18n('Identified') + ), + (metadata) => this.i18n(`device '{{device}}' on host '{{hostname}}'`, metadata) ) }; diff --git a/src/pybind/mgr/dashboard/services/orchestrator.py b/src/pybind/mgr/dashboard/services/orchestrator.py index f9114c21ceb26..6b6309f50e3d7 100644 --- a/src/pybind/mgr/dashboard/services/orchestrator.py +++ b/src/pybind/mgr/dashboard/services/orchestrator.py @@ -3,7 +3,7 @@ from __future__ import absolute_import import logging -from orchestrator import InventoryFilter +from orchestrator import InventoryFilter, DeviceLightLoc, Completion from orchestrator import OrchestratorClientMixin, raise_if_exception, OrchestratorError from .. import mgr from ..tools import wraps @@ -121,3 +121,8 @@ class OrchClient(object): def status(self): return self.api.status() + + @wait_api_result + def blink_device_light(self, hostname, device, ident_fault, on): + # type: (str, str, str, bool) -> Completion + return self.api.blink_device_light(ident_fault, on, [DeviceLightLoc(hostname, device)]) -- 2.39.5