]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add support for blinking enclosure LEDs 31851/head
authorVolker Theile <vtheile@suse.com>
Mon, 11 Nov 2019 15:30:50 +0000 (16:30 +0100)
committerVolker Theile <vtheile@suse.com>
Thu, 12 Dec 2019 12:52:33 +0000 (13:52 +0100)
Fixes: https://tracker.ceph.com/issues/42609
Signed-off-by: Volker Theile <vtheile@suse.com>
15 files changed:
src/pybind/mgr/dashboard/controllers/orchestrator.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/services/orchestrator.py

index f38ac90a87e07aa3e0f2449267261d4b140741a9..660203340d86591c95b86689814e7fede26cd924 100644 (file)
@@ -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):
index 224f4c5f3f69d603e56ccf37b45c1e48d2cf0a88..50759e0a7b4797a7d7e0ee5f394d8606d5e9bb63 100644 (file)
@@ -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]
index 208bb0d34f19f90df61b50725e23ae5ee0996279..197f6049a1eb39e8665a0fff05f56ca4ad8b3b8e 100644 (file)
@@ -5,7 +5,13 @@
           [selectionType]="selectionType"
           columnMode="flex"
           [autoReload]="false"
-          [searchField]="false">
+          [searchField]="false"
+          (updateSelection)="updateSelection($event)">
+  <cd-table-actions class="table-actions"
+                    [permission]="permission"
+                    [selection]="selection"
+                    [tableActions]="tableActions">
+  </cd-table-actions>
   <div class="table-filters form-inline"
        *ngIf="filters.length !== 0">
     <div class="form-group filter tc_filter"
index 03a0026ce4676230834a182953a44a7ffcec20c2..4a3a2da03d6e47868532ef4955f1d669aadb11ae 100644 (file)
@@ -1,8 +1,10 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { FormsModule } from '@angular/forms';
 
 import { getterForProp } from '@swimlane/ngx-datatable/release/utils';
 import * as _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
 
 import {
   configureTestBed,
@@ -89,7 +91,7 @@ describe('InventoryDevicesComponent', () => {
   ];
 
   configureTestBed({
-    imports: [FormsModule, SharedModule],
+    imports: [FormsModule, HttpClientTestingModule, SharedModule, ToastrModule.forRoot()],
     providers: [i18nProviders],
     declarations: [InventoryDevicesComponent]
   });
index 45f5dd7b2c22d1dfeb67b341c362753bb10db9a3..ab198492373f097c8be010d556db16d6e7763aad 100644 (file)
@@ -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<CdTableColumn> = [];
   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
+              })
+            );
+          });
+        }
+      }
+    });
+  }
 }
index f4455fbb8abfe73b872365eba81731ee3ab1f504..10a930f5b58c0022db20d642350d4b906feaa63a 100644 (file)
@@ -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]
   });
index f15f3a0382d1ede5337f23973eb937e79a286f27..bed5e0b0b19125a38907ab9cd6d8e3c326079484 100644 (file)
@@ -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]
   });
index d58da46fcd5745d93db7ad424b599a8d6ba98de9..0e08b85c5c87f11282636eb72441881868a37f5d 100644 (file)
@@ -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]
   });
index ee2648ac900fd73f300943e44a011523a63716ca..c882534520c6adc59b72e42bdc232ac6eaefa2ac 100644 (file)
@@ -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]
index f7d81d3f68ff434b1173c826556553061fa46882..94b0ed8e28f1824b80fc466b6fed3bcca03edb40 100644 (file)
@@ -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);
   });
index dbe70addea350ed4e7ae341b56674a094ff9d0cb..0b0cec2bf083df2b13826fc9b430489b2a29784a 100644 (file)
@@ -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<InventoryNode[]> {
     const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {};
-    return this.http.get<InventoryNode[]>(this.inventoryURL, options);
+    return this.http.get<InventoryNode[]>(`${this.url}/inventory`, options);
   }
 
   inventoryDeviceList(hostname?: string): Observable<InventoryDevice[]> {
@@ -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' });
   }
 }
index d1931943dc7ce47862cfc307d65cbcd3f042f977..35fedeb5f6d0a5448bb4e620905b95bbd1a3478a 100755 (executable)
                         i18n>This field is required.</span>
                 </div>
               </ng-template>
+              <ng-template [ngSwitchCase]="'select'">
+                <label *ngIf="field.label"
+                       class="col-form-label col-sm-3"
+                       [for]="field.name">
+                  {{ field.label }}
+                </label>
+                <div [ngClass]="{'col-sm-9': field.label, 'col-sm-12': !field.label}">
+                  <select class="form-control custom-select"
+                          [id]="field.name"
+                          [formControlName]="field.name">
+                    <option *ngIf="field.placeholder"
+                            [ngValue]="null">
+                      {{ field.placeholder }}
+                    </option>
+                    <option *ngFor="let option of field.options"
+                            [value]="option.value">
+                      {{ option.text }}
+                    </option>
+                  </select>
+                  <span *ngIf="formGroup.hasError('required', field.name)"
+                        class="invalid-feedback"
+                        i18n>This field is required.</span>
+                </div>
+              </ng-template>
             </ng-container>
           </div>
         </ng-container>
index a4da08b1160ca989c385861d625fddded1d1d2be..0572cd02ed5835751b9aa3c351e3739db34cbd36 100755 (executable)
@@ -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({
index 86a0def82f2eac93b8472f08c5dbc5ea49b71baa..e5ef68377425048f08da1edd8a4e53603d5dfa2d 100644 (file)
@@ -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)
     )
   };
 
index f9114c21ceb26dbfee77cf0d41408ce4c90e9b22..6b6309f50e3d704f9e1af68c9a47f3ecd1c1d20f 100644 (file)
@@ -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)])