]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add support for device management 30759/head
authorPatrick Seidensal <pseidensal@suse.com>
Wed, 2 Oct 2019 14:07:13 +0000 (16:07 +0200)
committerPatrick Seidensal <pseidensal@suse.com>
Fri, 25 Oct 2019 10:10:32 +0000 (12:10 +0200)
Adds two tabs named 'Devices' on the host and OSD page. The host
respectively OSD needs to be selected before the tab will be shown next
to the other tabs below the table where the host or OSD has been
selected. It will display the graphical representation of `ceph device
ls`, filtered by the selected host or OSD.

Fixes: https://tracker.ceph.com/issues/39352
Signed-off-by: Patrick Seidensal <pseidensal@suse.com>
24 files changed:
qa/tasks/mgr/dashboard/test_host.py
qa/tasks/mgr/dashboard/test_osd.py
src/pybind/mgr/dashboard/controllers/host.py
src/pybind/mgr/dashboard/controllers/osd.py
src/pybind/mgr/dashboard/frontend/e2e/cluster/osds.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html
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/hosts/hosts.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.spec.ts
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/shared/ceph-shared.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.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/models/devices.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts [new file with mode: 0644]

index 028ba515fe8065768beaf630e48b65fe6f0f3d61..158ef2125e507bf527eafb9957530c92c69f4ebc 100644 (file)
@@ -2,7 +2,7 @@
 from __future__ import absolute_import
 import json
 
-from .helper import DashboardTestCase
+from .helper import DashboardTestCase, JList, JObj
 from .test_orchestrator import test_data
 
 
@@ -73,3 +73,15 @@ class HostControllerTest(DashboardTestCase):
         test_hostnames = {inventory_node['name'] for inventory_node in test_data['inventory']}
         resp_hostnames = {host['hostname'] for host in data}
         self.assertEqual(len(test_hostnames.intersection(resp_hostnames)), 0)
+
+    def test_host_devices(self):
+        hosts = self._get('{}'.format(self.URL_HOST))
+        hosts = [host['hostname'] for host in hosts if host['hostname'] != '']
+        assert hosts[0]
+        data = self._get('{}/devices'.format('{}/{}'.format(self.URL_HOST, hosts[0])))
+        self.assertStatus(200)
+        self.assertSchema(data, JList(JObj({
+            'daemons': JList(str),
+            'devid': str,
+            'location': JList(JObj({'host': str, 'dev': str}))
+        })))
index fdd84ac7ea728604827257eb17e2a1e7d369aed9..03e9be150bd4f8ebee5c953d3f5421b12811b8d5 100644 (file)
@@ -118,6 +118,15 @@ class OsdTest(DashboardTestCase):
         self.wait_until_equal(get_destroy_status, False, 10)
         self.assertStatus(200)
 
+    def test_osd_devices(self):
+        data = self._get('/api/osd/0/devices')
+        self.assertStatus(200)
+        self.assertSchema(data, JList(JObj({
+            'daemons': JList(str),
+            'devid': str,
+            'location': JList(JObj({'host': str, 'dev': str}))
+        })))
+
 
 class OsdFlagsTest(DashboardTestCase):
     def __init__(self, *args, **kwargs):
index 26bff7bc8d4b7aa2344b66fe5d2cdcb86d5171c2..ba72bdc32ef0d72f08084beb119fed49c8955d84 100644 (file)
@@ -9,6 +9,7 @@ from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.orchestrator import OrchClient
 from ..services.exception import handle_orchestrator_error
+from ..services.ceph_service import CephService
 
 
 def host_task(name, metadata, wait_for=10.0):
@@ -102,3 +103,8 @@ class Host(RESTController):
                 msg='Remove a non-existent host {} from orchestrator'.format(
                     hostname),
                 component='orchestrator')
+
+    @RESTController.Resource('GET')
+    def devices(self, hostname):
+        # (str) -> dict
+        return CephService.send_command('mon', 'device ls-by-host', host=hostname)
index e9eb229aff235202ce8040e6215345b03837055b..b7a78f5187c42ea33a2488a5f27f9b8b61cb22b7 100644 (file)
@@ -216,6 +216,11 @@ class Osd(RESTController):
                 'is_safe_to_destroy': False,
             }
 
+    @RESTController.Resource('GET')
+    def devices(self, svc_id):
+        # (str) -> dict
+        return CephService.send_command('mon', 'device ls-by-daemon', who='osd.{}'.format(svc_id))
+
 
 @ApiController('/osd/flags', Scope.OSD)
 class OsdFlagsController(RESTController):
index cbccc435a4776d801aab2d2ce54ab3cfeb1ed1d9..7ab4bf3268eac28cbdc7960c43463a3a73bd5319 100644 (file)
@@ -1,11 +1,12 @@
-import { by, element } from 'protractor';
+import { $$, by, element } from 'protractor';
 import { OSDsPageHelper } from './osds.po';
 
 describe('OSDs page', () => {
   let osds: OSDsPageHelper;
 
-  beforeAll(() => {
+  beforeAll(async () => {
     osds = new OSDsPageHelper();
+    await osds.navigateTo();
   });
 
   afterEach(async () => {
@@ -13,10 +14,6 @@ describe('OSDs page', () => {
   });
 
   describe('breadcrumb and tab tests', () => {
-    beforeAll(async () => {
-      await osds.navigateTo();
-    });
-
     it('should open and show breadcrumb', async () => {
       await osds.waitTextToBePresent(osds.getBreadcrumb(), 'OSDs');
     });
@@ -36,37 +33,35 @@ describe('OSDs page', () => {
 
   describe('check existence of fields on OSD page', () => {
     it('should check that number of rows and count in footer match', async () => {
-      await osds.navigateTo();
       await expect(osds.getTableTotalCount()).toEqual(osds.getTableRows().count());
     });
 
     it('should verify that selected footer increases when an entry is clicked', async () => {
-      await osds.navigateTo();
-      await osds.getFirstCell().click(); // clicks first osd
+      await osds.getFirstCell().click();
       await expect(osds.getTableSelectedCount()).toEqual(1);
     });
 
     it('should verify that buttons exist', async () => {
-      await osds.navigateTo();
       await expect(element(by.cssContainingText('button', 'Scrub')).isPresent()).toBe(true);
       await expect(
         element(by.cssContainingText('button', 'Cluster-wide configuration')).isPresent()
       ).toBe(true);
     });
 
-    it('should check the number of tabs when selecting an osd is correct', async () => {
-      await osds.navigateTo();
-      await osds.getFirstCell().click(); // clicks first osd
-      await expect(osds.getTabsCount()).toEqual(8); // includes tabs at the top of the page
-    });
-
     it('should show the correct text for the tab labels', async () => {
-      await expect(osds.getTabText(2)).toEqual('Attributes (OSD map)');
-      await expect(osds.getTabText(3)).toEqual('Metadata');
-      await expect(osds.getTabText(4)).toEqual('Device health');
-      await expect(osds.getTabText(5)).toEqual('Performance counter');
-      await expect(osds.getTabText(6)).toEqual('Histogram');
-      await expect(osds.getTabText(7)).toEqual('Performance Details');
+      await osds.getFirstCell().click();
+      const tabHeadings = $$('#tabset-osd-details > div > tab').map((e) =>
+        e.getAttribute('heading')
+      );
+      await expect(tabHeadings).toEqual([
+        'Devices',
+        'Attributes (OSD map)',
+        'Metadata',
+        'Device health',
+        'Performance counter',
+        'Histogram',
+        'Performance Details'
+      ]);
     });
   });
 });
index 5ed1a42f11e27c6b2e72ef33ccd220e8084622c6..57cd06841e2946996b88d1f3101602f8e9293e43 100644 (file)
@@ -16,6 +16,7 @@ import { TypeaheadModule } from 'ngx-bootstrap/typeahead';
 
 import { SharedModule } from '../../shared/shared.module';
 import { PerformanceCounterModule } from '../performance-counter/performance-counter.module';
+import { CephSharedModule } from '../shared/ceph-shared.module';
 import { ConfigurationDetailsComponent } from './configuration/configuration-details/configuration-details.component';
 import { ConfigurationFormComponent } from './configuration/configuration-form/configuration-form.component';
 import { ConfigurationComponent } from './configuration/configuration.component';
@@ -72,7 +73,8 @@ import { ServicesComponent } from './services/services.component';
     TypeaheadModule.forRoot(),
     TimepickerModule.forRoot(),
     BsDatepickerModule.forRoot(),
-    NgBootstrapFormValidationModule
+    NgBootstrapFormValidationModule,
+    CephSharedModule
   ],
   declarations: [
     HostsComponent,
index d5a22d1ed9ad38fbe119fcfcf11b5933a17b2abd..41e15a46996f04c086ae8b94c035472cc9446740 100644 (file)
@@ -1,16 +1,18 @@
 <tabset *ngIf="selection.hasSingleSelection">
+  <tab i18n-heading
+       heading="Devices">
+    <cd-device-list [hostname]="selection.first()['hostname']"></cd-device-list>
+  </tab>
   <tab i18n-heading
        heading="Inventory"
        *ngIf="permissions.hosts.read">
-    <cd-inventory
-      [hostname]="selection.first()['hostname']">
+    <cd-inventory [hostname]="selection.first()['hostname']">
     </cd-inventory>
   </tab>
   <tab i18n-heading
        heading="Services"
        *ngIf="permissions.hosts.read">
-    <cd-services
-      [hostname]="selection.first()['hostname']">
+    <cd-services [hostname]="selection.first()['hostname']">
     </cd-services>
   </tab>
   <tab i18n-heading
index e89d6c869f2a72f40cbc714809f8c954c4399ea6..834ccea6aaf3c6d14804de9caeb488e33f7bd6c2 100644 (file)
@@ -7,12 +7,11 @@ import { TabsetComponent, TabsModule } from 'ngx-bootstrap/tabs';
 
 import { RouterTestingModule } from '@angular/router/testing';
 import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper';
+import { CoreModule } from '../../../../core/core.module';
 import { OrchestratorService } from '../../../../shared/api/orchestrator.service';
 import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
 import { Permissions } from '../../../../shared/models/permissions';
-import { SharedModule } from '../../../../shared/shared.module';
-import { InventoryComponent } from '../../inventory/inventory.component';
-import { ServicesComponent } from '../../services/services.component';
+import { CephModule } from '../../../ceph.module';
 import { HostDetailsComponent } from './host-details.component';
 
 describe('HostDetailsComponent', () => {
@@ -25,9 +24,10 @@ describe('HostDetailsComponent', () => {
       TabsModule.forRoot(),
       BsDropdownModule.forRoot(),
       RouterTestingModule,
-      SharedModule
+      CephModule,
+      CoreModule
     ],
-    declarations: [HostDetailsComponent, InventoryComponent, ServicesComponent],
+    declarations: [],
     providers: [i18nProviders]
   });
 
@@ -68,11 +68,8 @@ describe('HostDetailsComponent', () => {
 
     it('should show tabs', () => {
       fixture.detectChanges();
-      const tabs = component.tabsetChild.tabs;
-      expect(tabs.length).toBe(3);
-      expect(tabs[0].heading).toBe('Inventory');
-      expect(tabs[1].heading).toBe('Services');
-      expect(tabs[2].heading).toBe('Performance Details');
+      const tabs = component.tabsetChild.tabs.map((tab) => tab.heading);
+      expect(tabs).toEqual(['Devices', 'Inventory', 'Services', 'Performance Details']);
     });
   });
 });
index 73bddde6e53e11e18ae5bfa98b73ba5793fd0ea1..2d8e16c4748cde2c43e0f69473e5b014fe79f400 100644 (file)
@@ -8,13 +8,12 @@ import { TabsModule } from 'ngx-bootstrap/tabs';
 import { ToastrModule } from 'ngx-toastr';
 import { of } from 'rxjs';
 import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { CoreModule } from '../../../core/core.module';
 import { HostService } from '../../../shared/api/host.service';
 import { Permissions } from '../../../shared/models/permissions';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { SharedModule } from '../../../shared/shared.module';
-import { InventoryComponent } from '../inventory/inventory.component';
-import { ServicesComponent } from '../services/services.component';
-import { HostDetailsComponent } from './host-details/host-details.component';
+import { CephModule } from '../../ceph.module';
 import { HostsComponent } from './hosts.component';
 
 describe('HostsComponent', () => {
@@ -35,10 +34,12 @@ describe('HostsComponent', () => {
       TabsModule.forRoot(),
       BsDropdownModule.forRoot(),
       RouterTestingModule,
-      ToastrModule.forRoot()
+      ToastrModule.forRoot(),
+      CephModule,
+      CoreModule
     ],
     providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }, i18nProviders],
-    declarations: [HostsComponent, HostDetailsComponent, InventoryComponent, ServicesComponent]
+    declarations: []
   });
 
   beforeEach(() => {
index 708b0d324368ca38c2f8f0d5fb80598611c011be..1f4b137aa6f193a301e4f989e0d58ca5016a7b93 100644 (file)
@@ -1,4 +1,10 @@
-<tabset *ngIf="selection.hasSingleSelection">
+<tabset *ngIf="selection.hasSingleSelection"
+        id="tabset-osd-details">
+  <tab heading="Devices"
+       i18n-heading>
+    <cd-device-list *ngIf="osd.loaded && osd.id !== null"
+                    [osdId]="osd.id"></cd-device-list>
+  </tab>
 
   <tab heading="Attributes (OSD map)"
        i18n-heading>
@@ -14,7 +20,8 @@
                         [data]="osd.details.osd_metadata">
     </cd-table-key-value>
     <ng-template #noMetaData>
-      <cd-alert-panel type="warning" i18n>Metadata not available</cd-alert-panel>
+      <cd-alert-panel type="warning"
+                      i18n>Metadata not available</cd-alert-panel>
     </ng-template>
   </tab>
 
index 1dce0652b5cefac4e4e1cc62f8f4eed928f92a04..5ab90157ed95f83e773cd5c92197c12e101f1ef6 100644 (file)
@@ -7,12 +7,11 @@ import { of } from 'rxjs';
 import { TabsModule } from 'ngx-bootstrap/tabs';
 
 import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper';
+import { CoreModule } from '../../../../core/core.module';
 import { OsdService } from '../../../../shared/api/osd.service';
 import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
-import { SharedModule } from '../../../../shared/shared.module';
+import { CephModule } from '../../../ceph.module';
 import { PerformanceCounterModule } from '../../../performance-counter/performance-counter.module';
-import { OsdPerformanceHistogramComponent } from '../osd-performance-histogram/osd-performance-histogram.component';
-import { OsdSmartListComponent } from '../osd-smart-list/osd-smart-list.component';
 import { OsdDetailsComponent } from './osd-details.component';
 
 describe('OsdDetailsComponent', () => {
@@ -27,9 +26,10 @@ describe('OsdDetailsComponent', () => {
       HttpClientTestingModule,
       TabsModule.forRoot(),
       PerformanceCounterModule,
-      SharedModule
+      CephModule,
+      CoreModule
     ],
-    declarations: [OsdDetailsComponent, OsdPerformanceHistogramComponent, OsdSmartListComponent],
+    declarations: [],
     providers: i18nProviders
   });
 
index a26a34d713ad1ca1967c0d52fbc43d848410bb19..fae9544e2065fb7d903c026c8dc184623649f06a 100644 (file)
@@ -14,6 +14,7 @@ import {
   i18nProviders,
   PermissionHelper
 } from '../../../../../testing/unit-test-helper';
+import { CoreModule } from '../../../../core/core.module';
 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';
@@ -22,12 +23,9 @@ import { CdTableAction } from '../../../../shared/models/cd-table-action';
 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 { CephModule } from '../../../ceph.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 { OsdSmartListComponent } from '../osd-smart-list/osd-smart-list.component';
 import { OsdListComponent } from './osd-list.component';
 
 describe('OsdListComponent', () => {
@@ -81,16 +79,12 @@ describe('OsdListComponent', () => {
       HttpClientTestingModule,
       PerformanceCounterModule,
       TabsModule.forRoot(),
-      SharedModule,
+      CephModule,
       ReactiveFormsModule,
-      RouterTestingModule
-    ],
-    declarations: [
-      OsdListComponent,
-      OsdDetailsComponent,
-      OsdPerformanceHistogramComponent,
-      OsdSmartListComponent
+      RouterTestingModule,
+      CoreModule
     ],
+    declarations: [],
     providers: [
       { provide: AuthStorageService, useValue: fakeAuthStorageService },
       TableActionsComponent,
index aaf0ddcf7b9a75ad196129e169c30e65548afdc7..93f4ad77e8bee524ed6481c83f9a0bfb41d81aa9 100644 (file)
@@ -1,7 +1,12 @@
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
+import { DataTableModule } from '../../shared/datatable/datatable.module';
+import { SharedModule } from '../../shared/shared.module';
+import { DeviceListComponent } from './device-list/device-list.component';
 
 @NgModule({
-  imports: [CommonModule]
+  imports: [CommonModule, DataTableModule, SharedModule],
+  exports: [DeviceListComponent],
+  declarations: [DeviceListComponent]
 })
 export class CephSharedModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.html
new file mode 100644 (file)
index 0000000..79eb99b
--- /dev/null
@@ -0,0 +1,48 @@
+<cd-table *ngIf="hostname || osdId !== null"
+          [data]="devices"
+          [columns]="columns"></cd-table>
+
+<cd-alert-panel type="warning"
+                *ngIf="hostname === '' && osdId === null"
+                i18n>Neither hostname nor OSD ID given</cd-alert-panel>
+
+<ng-template #deviceLocation
+             let-value="value">
+  <span *ngFor="let location of value">{{location.dev}}</span>
+</ng-template>
+
+<ng-template #lifeExpectancy
+             let-value="value">
+  <span *ngIf="value.min && !value.max">&gt; {{value.min | i18nPlural: translationMapping}}</span>
+  <span *ngIf="value.max && !value.min">&lt; {{value.max | i18nPlural: translationMapping}}</span>
+  <span *ngIf="value.max && value.min">{{value.min}} to {{value.max | i18nPlural: translationMapping}}</span>
+</ng-template>
+
+<ng-template #lifeExpectancyTimestamp
+             let-value="value">
+  {{value}}
+</ng-template>
+
+<ng-template #state
+             let-value="value">
+  <ng-container *ngIf="value === 'good'">
+    <span class="badge badge-success"
+          i18n>Good</span>
+  </ng-container>
+  <ng-container *ngIf="value === 'warning'">
+    <span class="badge badge-warning"
+          i18n>Warning</span>
+  </ng-container>
+  <ng-container *ngIf="value === 'bad'">
+    <span class="badge badge-danger"
+          i18n>Bad</span>
+  </ng-container>
+  <ng-container *ngIf="value === 'stale'">
+    <span class="badge badge-info"
+          i18n>Stale</span>
+  </ng-container>
+  <ng-container *ngIf="value === 'unknown'">
+    <span class="badge badge-dark"
+          i18n>Unknown</span>
+  </ng-container>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.spec.ts
new file mode 100644 (file)
index 0000000..63aa775
--- /dev/null
@@ -0,0 +1,29 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { i18nProviders } from '../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../shared/shared.module';
+
+import { DeviceListComponent } from './device-list.component';
+
+describe('DeviceListComponent', () => {
+  let component: DeviceListComponent;
+  let fixture: ComponentFixture<DeviceListComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [DeviceListComponent],
+      imports: [SharedModule, HttpClientTestingModule],
+      providers: [i18nProviders]
+    }).compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(DeviceListComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.ts
new file mode 100644 (file)
index 0000000..3aeab98
--- /dev/null
@@ -0,0 +1,73 @@
+import { DatePipe } from '@angular/common';
+import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import { HostService } from '../../../shared/api/host.service';
+import { OsdService } from '../../../shared/api/osd.service';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CdDevice } from '../../../shared/models/devices';
+
+@Component({
+  selector: 'cd-device-list',
+  templateUrl: './device-list.component.html',
+  styleUrls: ['./device-list.component.scss']
+})
+export class DeviceListComponent implements OnInit {
+  @Input()
+  hostname = '';
+  @Input()
+  osdId = null;
+
+  @ViewChild('deviceLocation', { static: true })
+  locationTemplate: TemplateRef<any>;
+  @ViewChild('lifeExpectancy', { static: true })
+  lifeExpectancyTemplate: TemplateRef<any>;
+  @ViewChild('lifeExpectancyTimestamp', { static: true })
+  lifeExpectancyTimestampTemplate: TemplateRef<any>;
+  @ViewChild('state', { static: true })
+  stateTemplate: TemplateRef<any>;
+
+  devices: CdDevice[] = null;
+  columns: CdTableColumn[] = [];
+  translationMapping = {
+    '=1': '# week',
+    other: '# weeks'
+  };
+
+  constructor(
+    private hostService: HostService,
+    private i18n: I18n,
+    private datePipe: DatePipe,
+    private osdService: OsdService
+  ) {}
+
+  ngOnInit() {
+    const updateDevicesFn = (devices) => (this.devices = devices);
+    if (this.hostname) {
+      this.hostService.getDevices(this.hostname).subscribe(updateDevicesFn);
+    } else if (this.osdId !== null) {
+      this.osdService.getDevices(this.osdId).subscribe(updateDevicesFn);
+    }
+    this.columns = [
+      { prop: 'devid', name: this.i18n('Device ID'), minWidth: 200 },
+      {
+        prop: 'state',
+        name: this.i18n('State of Health'),
+        cellTemplate: this.stateTemplate
+      },
+      {
+        prop: 'life_expectancy_weeks',
+        name: this.i18n('Life Expectancy'),
+        cellTemplate: this.lifeExpectancyTemplate
+      },
+      {
+        prop: 'life_expectancy_stamp',
+        name: this.i18n('Prediction Creation Date'),
+        cellTemplate: this.lifeExpectancyTimestampTemplate,
+        pipe: this.datePipe,
+        isHidden: true
+      },
+      { prop: 'location', name: this.i18n('Device Name'), cellTemplate: this.locationTemplate },
+      { prop: 'readableDaemons', name: this.i18n('Daemons') }
+    ];
+  }
+}
index 8be0befc1d6956b4515694b2465da5334296d180..0f58cd098aff62433a5d97aceb69af524c7901e0 100644 (file)
@@ -35,4 +35,11 @@ describe('HostService', () => {
     tick();
     expect(result).toEqual(['foo', 'bar']);
   }));
+
+  it('should make a GET request on the devices endpoint when requesting devices', () => {
+    const hostname = 'hostname';
+    service.getDevices(hostname).subscribe();
+    const req = httpTesting.expectOne(`api/host/${hostname}/devices`);
+    expect(req.request.method).toBe('GET');
+  });
 });
index 453de9587e8d304a9c518803fe2ad3c4f4e0cc2e..26e440d8120392d56bff0fe4574e737aa5b73b4c 100644 (file)
@@ -1,6 +1,11 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { CdDevice } from '../models/devices';
+import { DeviceService } from '../services/device.service';
 import { ApiModule } from './api.module';
 
 @Injectable({
@@ -9,7 +14,7 @@ import { ApiModule } from './api.module';
 export class HostService {
   baseURL = 'api/host';
 
-  constructor(private http: HttpClient) {}
+  constructor(private http: HttpClient, private deviceService: DeviceService) {}
 
   list() {
     return this.http.get(this.baseURL);
@@ -22,4 +27,10 @@ export class HostService {
   remove(hostname) {
     return this.http.delete(`${this.baseURL}/${hostname}`, { observe: 'response' });
   }
+
+  getDevices(hostname: string): Observable<CdDevice[]> {
+    return this.http
+      .get<CdDevice[]>(`${this.baseURL}/${hostname}/devices`)
+      .pipe(map((devices) => devices.map((device) => this.deviceService.prepareDevice(device))));
+  }
 }
index fc1e52fff372420cf19a1779644000489abcf211..3f74eb9595949ee1e237874e39d6f9d4ab262205 100644 (file)
@@ -111,4 +111,10 @@ describe('OsdService', () => {
     const req = httpTesting.expectOne('api/osd/[0,1]/safe_to_destroy');
     expect(req.request.method).toBe('GET');
   });
+
+  it('should call the devices endpoint to retrieve smart data', () => {
+    service.getDevices(1).subscribe();
+    const req = httpTesting.expectOne('api/osd/1/devices');
+    expect(req.request.method).toBe('GET');
+  });
 });
index 008b877b149260a37900aa34e4b2e9c5a70c59d7..d1da3b7edf748d5548593e5d026ec4087c508607 100644 (file)
@@ -2,7 +2,10 @@ import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
 import { I18n } from '@ngx-translate/i18n-polyfill';
+import { map } from 'rxjs/operators';
 
+import { CdDevice } from '../models/devices';
+import { DeviceService } from '../services/device.service';
 import { ApiModule } from './api.module';
 
 export interface SmartAttribute {
@@ -178,7 +181,7 @@ export class OsdService {
     ]
   };
 
-  constructor(private http: HttpClient, private i18n: I18n) {}
+  constructor(private http: HttpClient, private i18n: I18n, private deviceService: DeviceService) {}
 
   getList() {
     return this.http.get(`${this.path}`);
@@ -250,4 +253,10 @@ export class OsdService {
     }
     return this.http.get<SafeToDestroyResponse>(`${this.path}/${ids}/safe_to_destroy`);
   }
+
+  getDevices(osdId: number) {
+    return this.http
+      .get<CdDevice[]>(`${this.path}/${osdId}/devices`)
+      .pipe(map((devices) => devices.map((device) => this.deviceService.prepareDevice(device))));
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts
new file mode 100644 (file)
index 0000000..90817c8
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * Fields returned by the back-end.
+ */
+export interface CephDevice {
+  devid: string;
+  location: { host: string; dev: string }[];
+  daemons: string[];
+  life_expectancy_min?: string;
+  life_expectancy_max?: string;
+  life_expectancy_stamp?: string;
+}
+
+/**
+ * Fields added by the front-end. Fields may be empty if no expectancy is provided for the
+ * CephDevice interface.
+ */
+export interface CdDevice extends CephDevice {
+  life_expectancy_weeks?: {
+    max: number;
+    min: number;
+  };
+  state?: 'good' | 'warning' | 'bad' | 'stale' | 'unknown';
+  readableDaemons?: string; // Human readable daemons (which can wrap lines inside the table cell)
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts
new file mode 100644 (file)
index 0000000..7992750
--- /dev/null
@@ -0,0 +1,92 @@
+import { TestBed } from '@angular/core/testing';
+
+import * as moment from 'moment';
+
+import { CdDevice } from '../models/devices';
+import { DeviceService } from './device.service';
+
+describe('DeviceService', () => {
+  let service: DeviceService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    service = TestBed.get(DeviceService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  describe('should test getDevices pipe', () => {
+    let now = null;
+
+    const newDevice = (data: object): CdDevice => {
+      const device: CdDevice = {
+        devid: '',
+        location: [{ host: '', dev: '' }],
+        daemons: []
+      };
+      Object.assign(device, data);
+      return device;
+    };
+
+    beforeEach(() => {
+      // Mock 'moment.now()' to simplify testing by enabling testing with fixed dates.
+      now = spyOn(moment, 'now').and.returnValue(
+        moment('2019-10-01T00:00:00.00000+0100').valueOf()
+      );
+    });
+
+    afterEach(() => {
+      expect(now).toHaveBeenCalled();
+    });
+
+    it('should return status "good" for life expectancy > 6 weeks', () => {
+      const preparedDevice = service.calculateAdditionalData(
+        newDevice({
+          life_expectancy_min: '2019-11-14T01:00:00.000000+0100',
+          life_expectancy_max: '0.000000',
+          life_expectancy_stamp: '2019-10-01T02:08:48.627312+0100'
+        })
+      );
+      expect(preparedDevice.life_expectancy_weeks).toEqual({ max: null, min: 6 });
+      expect(preparedDevice.state).toBe('good');
+    });
+
+    it('should return status "warning" for life expectancy <= 4 weeks', () => {
+      const preparedDevice = service.calculateAdditionalData(
+        newDevice({
+          life_expectancy_min: '2019-10-14T01:00:00.000000+0100',
+          life_expectancy_max: '2019-11-14T01:00:00.000000+0100',
+          life_expectancy_stamp: '2019-10-01T00:00:00.00000+0100'
+        })
+      );
+      expect(preparedDevice.life_expectancy_weeks).toEqual({ max: 6, min: 2 });
+      expect(preparedDevice.state).toBe('warning');
+    });
+
+    it('should return status "bad" for life expectancy <= 2 weeks', () => {
+      const preparedDevice = service.calculateAdditionalData(
+        newDevice({
+          life_expectancy_min: '0.000000',
+          life_expectancy_max: '2019-10-12T01:00:00.000000+0100',
+          life_expectancy_stamp: '2019-10-01T00:00:00.00000+0100'
+        })
+      );
+      expect(preparedDevice.life_expectancy_weeks).toEqual({ max: 2, min: null });
+      expect(preparedDevice.state).toBe('bad');
+    });
+
+    it('should return status "stale" for time stamp that is older than a week', () => {
+      const preparedDevice = service.calculateAdditionalData(
+        newDevice({
+          life_expectancy_min: '0.000000',
+          life_expectancy_max: '0.000000',
+          life_expectancy_stamp: '2019-09-21T00:00:00.00000+0100'
+        })
+      );
+      expect(preparedDevice.life_expectancy_weeks).toEqual({ max: null, min: null });
+      expect(preparedDevice.state).toBe('stale');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts
new file mode 100644 (file)
index 0000000..b83982f
--- /dev/null
@@ -0,0 +1,59 @@
+import { Injectable } from '@angular/core';
+
+import * as moment from 'moment';
+
+import { CdDevice } from '../models/devices';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class DeviceService {
+  constructor() {}
+
+  /**
+   * Calculates additional data and appends them as new attributes to the given device.
+   */
+  calculateAdditionalData(device: CdDevice): CdDevice {
+    if (!device.life_expectancy_min || !device.life_expectancy_max) {
+      device.state = 'unknown';
+      return device;
+    }
+    const hasDate = (float: string): boolean => !!Number.parseFloat(float);
+    const weeks = (isoDate1: string, isoDate2: string): number =>
+      !isoDate1 || !isoDate2 || !hasDate(isoDate1) || !hasDate(isoDate2)
+        ? null
+        : moment.duration(moment(isoDate1).diff(moment(isoDate2))).asWeeks();
+
+    const ageOfStamp = moment
+      .duration(moment(moment.now()).diff(moment(device.life_expectancy_stamp)))
+      .asWeeks();
+    const max = weeks(device.life_expectancy_max, device.life_expectancy_stamp);
+    const min = weeks(device.life_expectancy_min, device.life_expectancy_stamp);
+
+    if (ageOfStamp > 1) {
+      device.state = 'stale';
+    } else if (max !== null && max <= 2) {
+      device.state = 'bad';
+    } else if (min !== null && min <= 4) {
+      device.state = 'warning';
+    } else {
+      device.state = 'good';
+    }
+
+    device.life_expectancy_weeks = {
+      max: max !== null ? Math.round(max) : null,
+      min: min !== null ? Math.round(min) : null
+    };
+
+    return device;
+  }
+
+  readable(device: CdDevice): CdDevice {
+    device.readableDaemons = device.daemons.join(' ');
+    return device;
+  }
+
+  prepareDevice(device: CdDevice): CdDevice {
+    return this.readable(this.calculateAdditionalData(device));
+  }
+}