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 028ba515fe80..158ef2125e50 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 fdd84ac7ea72..03e9be150bd4 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 26bff7bc8d4b..ba72bdc32ef0 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 e9eb229aff23..b7a78f5187c4 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 cbccc435a477..7ab4bf3268ea 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 5ed1a42f11e2..57cd06841e29 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 d5a22d1ed9ad..41e15a46996f 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 73bddde6e53e..2d8e16c4748c 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 708b0d324368..1f4b137aa6f1 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 1dce0652b5ce..5ab90157ed95 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 a26a34d713ad..fae9544e2065 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 aaf0ddcf7b9a..93f4ad77e8be 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 000000000000..79eb99b45501 --- /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 000000000000..e69de29bb2d1 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 000000000000..63aa7755bf6b --- /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 000000000000..3aeab98c5ac7 --- /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 8be0befc1d69..0f58cd098aff 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 453de9587e8d..26e440d81203 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 fc1e52fff372..3f74eb959594 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 008b877b1492..d1da3b7edf74 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 000000000000..90817c89f9d0 --- /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 000000000000..79927505e58a --- /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 000000000000..b83982fb8dcc --- /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)); + } +}