From: Patrick Seidensal Date: Wed, 2 Oct 2019 14:07:13 +0000 (+0200) Subject: mgr/dashboard: Add support for device management X-Git-Tag: v15.1.0~1023^2 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=3eb67129c799d9cd6dcd369d55df1247bffea665;p=ceph.git mgr/dashboard: Add support for device management 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 --- diff --git a/qa/tasks/mgr/dashboard/test_host.py b/qa/tasks/mgr/dashboard/test_host.py index 028ba515fe8..158ef2125e5 100644 --- a/qa/tasks/mgr/dashboard/test_host.py +++ b/qa/tasks/mgr/dashboard/test_host.py @@ -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})) + }))) diff --git a/qa/tasks/mgr/dashboard/test_osd.py b/qa/tasks/mgr/dashboard/test_osd.py index fdd84ac7ea7..03e9be150bd 100644 --- a/qa/tasks/mgr/dashboard/test_osd.py +++ b/qa/tasks/mgr/dashboard/test_osd.py @@ -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): diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py index 26bff7bc8d4..ba72bdc32ef 100644 --- a/src/pybind/mgr/dashboard/controllers/host.py +++ b/src/pybind/mgr/dashboard/controllers/host.py @@ -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) diff --git a/src/pybind/mgr/dashboard/controllers/osd.py b/src/pybind/mgr/dashboard/controllers/osd.py index e9eb229aff2..b7a78f5187c 100644 --- a/src/pybind/mgr/dashboard/controllers/osd.py +++ b/src/pybind/mgr/dashboard/controllers/osd.py @@ -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): diff --git a/src/pybind/mgr/dashboard/frontend/e2e/cluster/osds.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/e2e/cluster/osds.e2e-spec.ts index cbccc435a47..7ab4bf3268e 100644 --- a/src/pybind/mgr/dashboard/frontend/e2e/cluster/osds.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/e2e/cluster/osds.e2e-spec.ts @@ -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' + ]); }); }); }); 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 5ed1a42f11e..57cd06841e2 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 @@ -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, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html index d5a22d1ed9a..41e15a46996 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html @@ -1,16 +1,18 @@ + + + - + - + { @@ -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']); }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts index 73bddde6e53..2d8e16c4748 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts @@ -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(() => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.html index 708b0d32436..1f4b137aa6f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.html @@ -1,4 +1,10 @@ - + + + + @@ -14,7 +20,8 @@ [data]="osd.details.osd_metadata"> - Metadata not available + Metadata not available diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.spec.ts index 1dce0652b5c..5ab90157ed9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-details/osd-details.component.spec.ts @@ -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 }); 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 a26a34d713a..fae9544e206 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 @@ -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, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts index aaf0ddcf7b9..93f4ad77e8b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts @@ -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 index 00000000000..79eb99b4550 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.html @@ -0,0 +1,48 @@ + + +Neither hostname nor OSD ID given + + + {{location.dev}} + + + + > {{value.min | i18nPlural: translationMapping}} + < {{value.max | i18nPlural: translationMapping}} + {{value.min}} to {{value.max | i18nPlural: translationMapping}} + + + + {{value}} + + + + + Good + + + Warning + + + Bad + + + Stale + + + Unknown + + 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 index 00000000000..e69de29bb2d 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 index 00000000000..63aa7755bf6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.spec.ts @@ -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; + + 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 index 00000000000..3aeab98c5ac --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/device-list/device-list.component.ts @@ -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; + @ViewChild('lifeExpectancy', { static: true }) + lifeExpectancyTemplate: TemplateRef; + @ViewChild('lifeExpectancyTimestamp', { static: true }) + lifeExpectancyTimestampTemplate: TemplateRef; + @ViewChild('state', { static: true }) + stateTemplate: TemplateRef; + + 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') } + ]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts index 8be0befc1d6..0f58cd098af 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts @@ -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'); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts index 453de9587e8..26e440d8120 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts @@ -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 { + return this.http + .get(`${this.baseURL}/${hostname}/devices`) + .pipe(map((devices) => devices.map((device) => this.deviceService.prepareDevice(device)))); + } } 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 fc1e52fff37..3f74eb95959 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 @@ -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'); + }); }); 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 008b877b149..d1da3b7edf7 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 @@ -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(`${this.path}/${ids}/safe_to_destroy`); } + + getDevices(osdId: number) { + return this.http + .get(`${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 index 00000000000..90817c89f9d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts @@ -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 index 00000000000..79927505e58 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts @@ -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 index 00000000000..b83982fb8dc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts @@ -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)); + } +}