From: Kiefer Chang Date: Tue, 15 Oct 2019 03:03:13 +0000 (+0800) Subject: mgr/dashboard: support creating OSDs on spare devices X-Git-Tag: v15.1.0~727^2~3 X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=632556864f2167b7af41af9aff144b1d0b420378;p=ceph-ci.git mgr/dashboard: support creating OSDs on spare devices On OSD page, a form is added to allow creating OSDs from non-occupied devices. User can: - use filters to select some primary devices for OSD. - use filters to select WAL/DB devices as shared devices if needed. Note: This feature requires orchestrator support. Frontend changes: - Extract inventory devices component from inventory. We need to reuse it. - The inventory devices component supports column filters. The available options for filters are determined by all possible values in a column. - Add a button on OSD list page to display OSD creation form. - Add OSD creation form, it allows selecting primary/WAL/DB devices for OSDs. - Add a preview modal to preview OSD creation. Currently, the Drive Group Specification is displayed. This feature will be completed when we have library to preview OSD creation. Fixes: https://tracker.ceph.com/issues/40335 Fixes: https://tracker.ceph.com/issues/42076 Fixes: https://tracker.ceph.com/issues/42882 Signed-off-by: Kiefer Chang --- 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 8671d5a5091..f4a08281ae7 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 @@ -37,7 +37,7 @@ describe('OSDs page', () => { }); it('should verify that buttons exist', async () => { - await expect(element(by.cssContainingText('button', 'Scrub')).isPresent()).toBe(true); + await expect(element(by.cssContainingText('button', 'Create')).isPresent()).toBe(true); await expect( element(by.cssContainingText('button', 'Cluster-wide configuration')).isPresent() ).toBe(true); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 875ca13f5fb..a483ba78bd6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -14,6 +14,7 @@ import { LogsComponent } from './ceph/cluster/logs/logs.component'; import { MgrModuleFormComponent } from './ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component'; import { MgrModuleListComponent } from './ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component'; import { MonitorComponent } from './ceph/cluster/monitor/monitor.component'; +import { OsdFormComponent } from './ceph/cluster/osd/osd-form/osd-form.component'; import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component'; import { AlertListComponent } from './ceph/cluster/prometheus/alert-list/alert-list.component'; import { SilenceFormComponent } from './ceph/cluster/prometheus/silence-form/silence-form.component'; @@ -107,7 +108,14 @@ const routes: Routes = [ canActivate: [AuthGuardService], canActivateChild: [AuthGuardService], data: { breadcrumbs: 'Cluster/OSDs' }, - children: [{ path: '', component: OsdListComponent }] + children: [ + { path: '', component: OsdListComponent }, + { + path: URLVerbs.CREATE, + component: OsdFormComponent, + data: { breadcrumbs: ActionLabels.CREATE } + } + ] }, { path: 'configuration', 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 57cd06841e2..25b22a7157c 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 @@ -24,12 +24,17 @@ import { CrushmapComponent } from './crushmap/crushmap.component'; import { HostDetailsComponent } from './hosts/host-details/host-details.component'; import { HostFormComponent } from './hosts/host-form/host-form.component'; import { HostsComponent } from './hosts/hosts.component'; +import { InventoryDevicesComponent } from './inventory/inventory-devices/inventory-devices.component'; import { InventoryComponent } from './inventory/inventory.component'; import { LogsComponent } from './logs/logs.component'; import { MgrModulesModule } from './mgr-modules/mgr-modules.module'; import { MonitorComponent } from './monitor/monitor.component'; +import { OsdCreationPreviewModalComponent } from './osd/osd-creation-preview-modal/osd-creation-preview-modal.component'; import { OsdDetailsComponent } from './osd/osd-details/osd-details.component'; +import { OsdDevicesSelectionGroupsComponent } from './osd/osd-devices-selection-groups/osd-devices-selection-groups.component'; +import { OsdDevicesSelectionModalComponent } from './osd/osd-devices-selection-modal/osd-devices-selection-modal.component'; import { OsdFlagsModalComponent } from './osd/osd-flags-modal/osd-flags-modal.component'; +import { OsdFormComponent } from './osd/osd-form/osd-form.component'; import { OsdListComponent } from './osd/osd-list/osd-list.component'; import { OsdPerformanceHistogramComponent } from './osd/osd-performance-histogram/osd-performance-histogram.component'; import { OsdPgScrubModalComponent } from './osd/osd-pg-scrub-modal/osd-pg-scrub-modal.component'; @@ -53,7 +58,9 @@ import { ServicesComponent } from './services/services.component'; OsdReweightModalComponent, OsdPgScrubModalComponent, OsdReweightModalComponent, - SilenceMatcherModalComponent + SilenceMatcherModalComponent, + OsdDevicesSelectionModalComponent, + OsdCreationPreviewModalComponent ], imports: [ CommonModule, @@ -102,7 +109,12 @@ import { ServicesComponent } from './services/services.component'; ServicesComponent, InventoryComponent, HostFormComponent, - OsdSmartListComponent + OsdSmartListComponent, + OsdFormComponent, + OsdDevicesSelectionModalComponent, + InventoryDevicesComponent, + OsdDevicesSelectionGroupsComponent, + OsdCreationPreviewModalComponent ] }) export class ClusterModule {} 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 41e15a46996..c38b669c82b 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 @@ -12,7 +12,9 @@ - + { HttpClientTestingModule, TabsModule.forRoot(), BsDropdownModule.forRoot(), + NgBootstrapFormValidationModule.forRoot(), RouterTestingModule, CephModule, CoreModule @@ -41,7 +42,7 @@ describe('HostDetailsComponent', () => { }); const orchService = TestBed.get(OrchestratorService); spyOn(orchService, 'status').and.returnValue(of({ available: true })); - spyOn(orchService, 'inventoryList').and.returnValue(of([])); + spyOn(orchService, 'inventoryDeviceList').and.returnValue(of([])); spyOn(orchService, 'serviceList').and.returnValue(of([])); fixture.detectChanges(); }); 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 2d8e16c4748..9ad1fde6944 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 @@ -4,9 +4,9 @@ import { RouterTestingModule } from '@angular/router/testing'; import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; import { TabsModule } from 'ngx-bootstrap/tabs'; - import { ToastrModule } from 'ngx-toastr'; import { of } from 'rxjs'; + import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; import { CoreModule } from '../../../core/core.module'; import { HostService } from '../../../shared/api/host.service'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-applied-filters.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-applied-filters.interface.ts new file mode 100644 index 00000000000..a1dc1283a08 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-applied-filters.interface.ts @@ -0,0 +1,6 @@ +export interface InventoryDeviceAppliedFilter { + label: string; + prop: string; + value: string; + formatValue: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-filter.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-filter.interface.ts new file mode 100644 index 00000000000..0f46bff3599 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-filter.interface.ts @@ -0,0 +1,13 @@ +import { PipeTransform } from '@angular/core'; + +export interface InventoryDeviceFilter { + label: string; + prop: string; + initValue: string; + value: string; + options: { + value: string; + formatValue: string; + }[]; + pipe?: PipeTransform; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-filters-change-event.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-filters-change-event.interface.ts new file mode 100644 index 00000000000..b85f6f0628f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-filters-change-event.interface.ts @@ -0,0 +1,8 @@ +import { InventoryDeviceAppliedFilter } from './inventory-device-applied-filters.interface'; +import { InventoryDevice } from './inventory-device.model'; + +export interface InventoryDeviceFiltersChangeEvent { + filters: InventoryDeviceAppliedFilter[]; + filterInDevices: InventoryDevice[]; + filterOutDevices: InventoryDevice[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device.model.ts new file mode 100644 index 00000000000..4af9137de08 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device.model.ts @@ -0,0 +1,20 @@ +export class SysAPI { + vendor: string; + model: string; + size: number; + rotational: string; + human_readable_size: string; +} + +export class InventoryDevice { + hostname: string; + uid: string; + + path: string; + sys_api: SysAPI; + available: boolean; + rejected_reasons: string[]; + device_id: string; + human_readable_type: string; + osd_ids: number[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html new file mode 100644 index 00000000000..f9a285427e8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html @@ -0,0 +1,43 @@ + +
+
+ + +
+
+ +
+
+
+ + + + osd.{{ osdId }} +   + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.scss new file mode 100644 index 00000000000..e2eb0350c74 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.scss @@ -0,0 +1,12 @@ +.filter { + padding-right: 8px; +} + +.fa-stack { + font-size: 0.79rem; + + .fa-stack-1x { + margin-left: 8px; + margin-top: 5px; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts new file mode 100644 index 00000000000..03a0026ce46 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts @@ -0,0 +1,166 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; + +import { getterForProp } from '@swimlane/ngx-datatable/release/utils'; +import * as _ from 'lodash'; + +import { + configureTestBed, + FixtureHelper, + i18nProviders +} from '../../../../../testing/unit-test-helper'; +import { SharedModule } from '../../../../shared/shared.module'; +import { InventoryDevice } from './inventory-device.model'; +import { InventoryDevicesComponent } from './inventory-devices.component'; + +describe('InventoryDevicesComponent', () => { + let component: InventoryDevicesComponent; + let fixture: ComponentFixture; + let fixtureHelper: FixtureHelper; + const devices: InventoryDevice[] = [ + { + hostname: 'node0', + uid: '1', + path: 'sda', + sys_api: { + vendor: 'AAA', + model: 'aaa', + size: 1024, + rotational: 'false', + human_readable_size: '1 KB' + }, + available: false, + rejected_reasons: [''], + device_id: 'AAA-aaa-id0', + human_readable_type: 'nvme/ssd', + osd_ids: [] + }, + { + hostname: 'node0', + uid: '2', + path: 'sdb', + sys_api: { + vendor: 'AAA', + model: 'aaa', + size: 1024, + rotational: 'false', + human_readable_size: '1 KB' + }, + available: true, + rejected_reasons: [''], + device_id: 'AAA-aaa-id1', + human_readable_type: 'nvme/ssd', + osd_ids: [] + }, + { + hostname: 'node0', + uid: '3', + path: 'sdc', + sys_api: { + vendor: 'BBB', + model: 'bbb', + size: 2048, + rotational: 'true', + human_readable_size: '2 KB' + }, + available: true, + rejected_reasons: [''], + device_id: 'BBB-bbbb-id0', + human_readable_type: 'hdd', + osd_ids: [] + }, + { + hostname: 'node1', + uid: '4', + path: 'sda', + sys_api: { + vendor: 'CCC', + model: 'ccc', + size: 1024, + rotational: 'true', + human_readable_size: '1 KB' + }, + available: false, + rejected_reasons: [''], + device_id: 'CCC-cccc-id0', + human_readable_type: 'hdd', + osd_ids: [] + } + ]; + + configureTestBed({ + imports: [FormsModule, SharedModule], + providers: [i18nProviders], + declarations: [InventoryDevicesComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(InventoryDevicesComponent); + fixtureHelper = new FixtureHelper(fixture); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('without device data', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should have columns that are sortable', () => { + expect(component.columns.every((column) => Boolean(column.prop))).toBeTruthy(); + }); + + it('should have filters', () => { + const labelTexts = fixtureHelper.getTextAll('.tc_filter span:first-child'); + const filterLabels = _.map(component.filters, 'label'); + expect(labelTexts).toEqual(filterLabels); + + const optionTexts = fixtureHelper.getTextAll('.tc_filter option'); + expect(optionTexts).toEqual(_.map(component.filters, 'initValue')); + }); + }); + + describe('with device data', () => { + beforeEach(() => { + component.devices = devices; + fixture.detectChanges(); + }); + + it('should have filters', () => { + for (let i = 0; i < component.filters.length; i++) { + const optionTexts = fixtureHelper.getTextAll(`.tc_filter:nth-child(${i + 1}) option`); + const optionTextsSet = new Set(optionTexts); + + const filter = component.filters[i]; + const columnValues = devices.map((device: InventoryDevice) => { + const valueGetter = getterForProp(filter.prop); + const value = valueGetter(device, filter.prop); + const formatValue = filter.pipe ? filter.pipe.transform(value) : value; + return `${formatValue}`; + }); + const expectedOptionsSet = new Set(['*', ...columnValues]); + expect(optionTextsSet).toEqual(expectedOptionsSet); + } + }); + + it('should filter a single column', () => { + spyOn(component.filterChange, 'emit'); + fixtureHelper.selectElement('.tc_filter:nth-child(1) select', 'node1'); + expect(component.filterInDevices.length).toBe(1); + expect(component.filterInDevices[0]).toEqual(devices[3]); + expect(component.filterChange.emit).toHaveBeenCalled(); + }); + + it('should filter multiple columns', () => { + spyOn(component.filterChange, 'emit'); + fixtureHelper.selectElement('.tc_filter:nth-child(2) select', 'hdd'); + fixtureHelper.selectElement('.tc_filter:nth-child(1) select', 'node0'); + expect(component.filterInDevices.length).toBe(1); + expect(component.filterInDevices[0].uid).toBe('3'); + expect(component.filterChange.emit).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts new file mode 100644 index 00000000000..1b8a00bb96b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts @@ -0,0 +1,200 @@ +import { + Component, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + TemplateRef, + ViewChild +} from '@angular/core'; +import { I18n } from '@ngx-translate/i18n-polyfill'; + +import { getterForProp } from '@swimlane/ngx-datatable/release/utils'; +import * as _ from 'lodash'; + +import { Icons } from '../../../../shared/enum/icons.enum'; +import { CdTableColumn } from '../../../../shared/models/cd-table-column'; +import { DimlessBinaryPipe } from '../../../../shared/pipes/dimless-binary.pipe'; +import { InventoryDeviceFilter } from './inventory-device-filter.interface'; +import { InventoryDeviceFiltersChangeEvent } from './inventory-device-filters-change-event.interface'; +import { InventoryDevice } from './inventory-device.model'; + +@Component({ + selector: 'cd-inventory-devices', + templateUrl: './inventory-devices.component.html', + styleUrls: ['./inventory-devices.component.scss'] +}) +export class InventoryDevicesComponent implements OnInit, OnChanges { + @ViewChild('osds', { static: true }) + osds: TemplateRef; + + // Devices + @Input() devices: InventoryDevice[] = []; + + // Do not display these columns + @Input() hiddenColumns: string[] = []; + + // Show filters for these columns, specify empty array to disable + @Input() filterColumns = [ + 'hostname', + 'human_readable_type', + 'available', + 'sys_api.vendor', + 'sys_api.model', + 'sys_api.size' + ]; + + // Device table row selection type + @Input() selectionType: string = undefined; + + @Output() filterChange = new EventEmitter(); + + filterInDevices: InventoryDevice[] = []; + filterOutDevices: InventoryDevice[] = []; + + icons = Icons; + columns: Array = []; + filters: InventoryDeviceFilter[] = []; + + constructor(private dimlessBinary: DimlessBinaryPipe, private i18n: I18n) {} + + ngOnInit() { + const columns = [ + { + name: this.i18n('Hostname'), + prop: 'hostname', + flexGrow: 1 + }, + { + name: this.i18n('Device path'), + prop: 'path', + flexGrow: 1 + }, + { + name: this.i18n('Type'), + prop: 'human_readable_type', + flexGrow: 1 + }, + { + name: this.i18n('Available'), + prop: 'available', + flexGrow: 1 + }, + { + name: this.i18n('Vendor'), + prop: 'sys_api.vendor', + flexGrow: 1 + }, + { + name: this.i18n('Model'), + prop: 'sys_api.model', + flexGrow: 1 + }, + { + name: this.i18n('Size'), + prop: 'sys_api.size', + flexGrow: 1, + pipe: this.dimlessBinary + }, + { + name: this.i18n('OSDs'), + prop: 'osd_ids', + flexGrow: 1, + cellTemplate: this.osds + } + ]; + + this.columns = columns.filter((col: any) => { + return !this.hiddenColumns.includes(col.prop); + }); + + // init filters + this.filters = this.columns + .filter((col: any) => { + return this.filterColumns.includes(col.prop); + }) + .map((col: any) => { + return { + label: col.name, + prop: col.prop, + initValue: '*', + value: '*', + options: [{ value: '*', formatValue: '*' }], + pipe: col.pipe + }; + }); + + this.filterInDevices = [...this.devices]; + this.updateFilterOptions(this.devices); + } + + ngOnChanges() { + this.updateFilterOptions(this.devices); + this.filterInDevices = [...this.devices]; + // TODO: apply filter, columns changes, filter changes + } + + updateFilterOptions(devices: InventoryDevice[]) { + // update filter options to all possible values in a column, might be time-consuming + this.filters.forEach((filter) => { + const values = _.sortedUniq(_.map(devices, filter.prop).sort()); + const options = values.map((v: string) => { + return { + value: v, + formatValue: filter.pipe ? filter.pipe.transform(v) : v + }; + }); + filter.options = [{ value: '*', formatValue: '*' }, ...options]; + }); + } + + doFilter() { + this.filterOutDevices = []; + const appliedFilters = []; + let devices: any = [...this.devices]; + this.filters.forEach((filter) => { + if (filter.value === filter.initValue) { + return; + } + appliedFilters.push({ + label: filter.label, + prop: filter.prop, + value: filter.value, + formatValue: filter.pipe ? filter.pipe.transform(filter.value) : filter.value + }); + // Separate devices to filter-in and filter-out parts. + // Cast column value to string type because options are always string. + const parts = _.partition(devices, (row) => { + // use getter from ngx-datatable for props like 'sys_api.size' + const valueGetter = getterForProp(filter.prop); + return `${valueGetter(row, filter.prop)}` === filter.value; + }); + devices = parts[0]; + this.filterOutDevices = [...this.filterOutDevices, ...parts[1]]; + }); + this.filterInDevices = devices; + this.filterChange.emit({ + filters: appliedFilters, + filterInDevices: this.filterInDevices, + filterOutDevices: this.filterOutDevices + }); + } + + onFilterChange() { + this.doFilter(); + } + + onFilterReset() { + this.filters.forEach((item) => { + item.value = item.initValue; + }); + this.filterInDevices = [...this.devices]; + this.filterOutDevices = []; + this.filterChange.emit({ + filters: [], + filterInDevices: this.filterInDevices, + filterOutDevices: this.filterOutDevices + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-node.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-node.model.ts new file mode 100644 index 00000000000..41c38b60c47 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-node.model.ts @@ -0,0 +1,6 @@ +import { InventoryDevice } from './inventory-devices/inventory-device.model'; + +export class InventoryNode { + name: string; + devices: InventoryDevice[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html index 01c4175efe7..8f573cf9810 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html @@ -9,22 +9,10 @@ Devices
- - + +
- - - - osd.{{ osdId }} -   - - diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts index 9e3fb85fc0b..f4455fbb8ab 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts @@ -1,77 +1,49 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; + import { of } from 'rxjs'; + import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; import { OrchestratorService } from '../../../shared/api/orchestrator.service'; -import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context'; import { SharedModule } from '../../../shared/shared.module'; +import { InventoryDevicesComponent } from './inventory-devices/inventory-devices.component'; import { InventoryComponent } from './inventory.component'; describe('InventoryComponent', () => { let component: InventoryComponent; let fixture: ComponentFixture; - let reqHostname: string; - - const inventoryNodes = [ - { - name: 'host0', - devices: [ - { - type: 'hdd', - id: '/dev/sda' - } - ] - }, - { - name: 'host1', - devices: [ - { - type: 'hdd', - id: '/dev/sda' - } - ] - } - ]; - - const getIventoryList = (hostname: String) => { - return hostname ? inventoryNodes.filter((node) => node.name === hostname) : inventoryNodes; - }; + let orchService: OrchestratorService; configureTestBed({ - imports: [SharedModule, HttpClientTestingModule, RouterTestingModule], + imports: [FormsModule, SharedModule, HttpClientTestingModule, RouterTestingModule], providers: [i18nProviders], - declarations: [InventoryComponent] + declarations: [InventoryComponent, InventoryDevicesComponent] }); beforeEach(() => { fixture = TestBed.createComponent(InventoryComponent); component = fixture.componentInstance; - const orchService = TestBed.get(OrchestratorService); + orchService = TestBed.get(OrchestratorService); spyOn(orchService, 'status').and.returnValue(of({ available: true })); - reqHostname = ''; - spyOn(orchService, 'inventoryList').and.callFake(() => of(getIventoryList(reqHostname))); - fixture.detectChanges(); + spyOn(orchService, 'inventoryDeviceList').and.callThrough(); }); it('should create', () => { expect(component).toBeTruthy(); }); - it('should have columns that are sortable', () => { - expect(component.columns.every((column) => Boolean(column.prop))).toBeTruthy(); - }); - - it('should return all devices', () => { - component.getInventory(new CdTableFetchDataContext(() => {})); - expect(component.devices.length).toBe(2); - }); - - it('should return devices on a host', () => { - reqHostname = 'host0'; - component.getInventory(new CdTableFetchDataContext(() => {})); - expect(component.devices.length).toBe(1); - expect(component.devices[0].hostname).toBe(reqHostname); + describe('after ngOnInit', () => { + it('should load devices', () => { + fixture.detectChanges(); + expect(orchService.inventoryDeviceList).toHaveBeenCalledWith(undefined); + }); + + it('should load devices for a host', () => { + component.hostname = 'host0'; + fixture.detectChanges(); + expect(orchService.inventoryDeviceList).toHaveBeenCalledWith('host0'); + }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts index 9bbcac8328c..f7481ec417d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts @@ -1,15 +1,10 @@ -import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core'; - -import { I18n } from '@ngx-translate/i18n-polyfill'; +import { Component, Input, OnChanges, OnInit } from '@angular/core'; import { OrchestratorService } from '../../../shared/api/orchestrator.service'; -import { TableComponent } from '../../../shared/datatable/table/table.component'; -import { CdTableColumn } from '../../../shared/models/cd-table-column'; -import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context'; +import { Icons } from '../../../shared/enum/icons.enum'; import { CephReleaseNamePipe } from '../../../shared/pipes/ceph-release-name.pipe'; -import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe'; import { SummaryService } from '../../../shared/services/summary.service'; -import { Device, InventoryNode } from './inventory.model'; +import { InventoryDevice } from './inventory-devices/inventory-device.model'; @Component({ selector: 'cd-inventory', @@ -17,79 +12,25 @@ import { Device, InventoryNode } from './inventory.model'; styleUrls: ['./inventory.component.scss'] }) export class InventoryComponent implements OnChanges, OnInit { - @ViewChild(TableComponent, { static: false }) - table: TableComponent; - @ViewChild('osds', { static: true }) - osds: TemplateRef; + // Display inventory page only for this hostname, ignore to display all. + @Input() hostname?: string; - @Input() hostname = ''; + icons = Icons; checkingOrchestrator = true; orchestratorExist = false; docsUrl: string; - columns: Array = []; - devices: Array = []; + devices: Array = []; isLoadingDevices = false; constructor( private cephReleaseNamePipe: CephReleaseNamePipe, - private dimlessBinary: DimlessBinaryPipe, - private i18n: I18n, private orchService: OrchestratorService, private summaryService: SummaryService ) {} ngOnInit() { - this.columns = [ - { - name: this.i18n('Device path'), - prop: 'path', - flexGrow: 1 - }, - { - name: this.i18n('Type'), - prop: 'human_readable_type', - flexGrow: 1 - }, - { - name: this.i18n('Size'), - prop: 'sys_api.size', - flexGrow: 1, - pipe: this.dimlessBinary - }, - { - name: this.i18n('Rotates'), - prop: 'sys_api.rotational', - flexGrow: 1 - }, - { - name: this.i18n('Available'), - prop: 'available', - flexGrow: 1 - }, - { - name: this.i18n('Model'), - prop: 'sys_api.model', - flexGrow: 1 - }, - { - name: this.i18n('OSDs'), - prop: 'osd_ids', - flexGrow: 1, - cellTemplate: this.osds - } - ]; - - if (!this.hostname) { - const hostColumn = { - name: this.i18n('Hostname'), - prop: 'hostname', - flexGrow: 1 - }; - this.columns.splice(0, 0, hostColumn); - } - // duplicated code with grafana const subs = this.summaryService.subscribe((summary: any) => { if (!summary) { @@ -107,38 +48,37 @@ export class InventoryComponent implements OnChanges, OnInit { this.orchService.status().subscribe((data: { available: boolean }) => { this.orchestratorExist = data.available; this.checkingOrchestrator = false; + + if (this.orchestratorExist) { + this.getInventory(); + } }); } ngOnChanges() { if (this.orchestratorExist) { this.devices = []; - this.table.reloadData(); + this.getInventory(); } } - getInventory(context: CdTableFetchDataContext) { + getInventory() { if (this.isLoadingDevices) { return; } this.isLoadingDevices = true; - this.orchService.inventoryList(this.hostname).subscribe( - (data: InventoryNode[]) => { - const devices: Device[] = []; - data.forEach((node: InventoryNode) => { - node.devices.forEach((device: Device) => { - device.hostname = node.name; - device.uid = `${node.name}-${device.device_id}`; - devices.push(device); - }); - }); + if (this.hostname === '') { + this.isLoadingDevices = false; + return; + } + this.orchService.inventoryDeviceList(this.hostname).subscribe( + (devices: InventoryDevice[]) => { this.devices = devices; this.isLoadingDevices = false; }, () => { - this.isLoadingDevices = false; this.devices = []; - context.error(); + this.isLoadingDevices = false; } ); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts deleted file mode 100644 index b7cd2ea9a05..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts +++ /dev/null @@ -1,25 +0,0 @@ -export class SysAPI { - vendor: string; - model: string; - size: number; - rotational: string; - human_readable_size: string; -} - -export class Device { - hostname: string; - uid: string; - osd_ids: number[]; - - path: string; - sys_api: SysAPI; - available: boolean; - rejected_reasons: string[]; - device_id: string; - human_readable_type: string; -} - -export class InventoryNode { - name: string; - devices: Device[]; -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.html new file mode 100644 index 00000000000..fd0acad491a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.html @@ -0,0 +1,20 @@ + + OSD creation preview + + +
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.spec.ts new file mode 100644 index 00000000000..36fe3b0e57c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.spec.ts @@ -0,0 +1,33 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { BsModalRef } from 'ngx-bootstrap/modal'; + +import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper'; +import { SharedModule } from '../../../../shared/shared.module'; +import { DriveGroup } from '../osd-form/drive-group.model'; +import { OsdCreationPreviewModalComponent } from './osd-creation-preview-modal.component'; + +describe('OsdCreationPreviewModalComponent', () => { + let component: OsdCreationPreviewModalComponent; + let fixture: ComponentFixture; + + configureTestBed({ + imports: [HttpClientTestingModule, ReactiveFormsModule, SharedModule, RouterTestingModule], + providers: [BsModalRef, i18nProviders], + declarations: [OsdCreationPreviewModalComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OsdCreationPreviewModalComponent); + component = fixture.componentInstance; + component.driveGroup = new DriveGroup(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.ts new file mode 100644 index 00000000000..82b14ae4795 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.ts @@ -0,0 +1,57 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; + +import { BsModalRef } from 'ngx-bootstrap/modal'; + +import { OrchestratorService } from '../../../../shared/api/orchestrator.service'; +import { ActionLabelsI18n } from '../../../../shared/constants/app.constants'; +import { CdFormBuilder } from '../../../../shared/forms/cd-form-builder'; +import { CdFormGroup } from '../../../../shared/forms/cd-form-group'; +import { DriveGroup } from '../osd-form/drive-group.model'; + +@Component({ + selector: 'cd-osd-creation-preview-modal', + templateUrl: './osd-creation-preview-modal.component.html', + styleUrls: ['./osd-creation-preview-modal.component.scss'] +}) +export class OsdCreationPreviewModalComponent implements OnInit { + @Input() + driveGroup: DriveGroup; + + @Input() + allHosts: string[]; + + @Output() + submitAction = new EventEmitter(); + + action: string; + formGroup: CdFormGroup; + + constructor( + public bsModalRef: BsModalRef, + public actionLabels: ActionLabelsI18n, + private formBuilder: CdFormBuilder, + private orchService: OrchestratorService + ) { + this.action = actionLabels.ADD; + this.createForm(); + } + + ngOnInit() {} + + createForm() { + this.formGroup = this.formBuilder.group({}); + } + + onSubmit() { + this.orchService.osdCreate(this.driveGroup.spec, this.allHosts).subscribe( + undefined, + () => { + this.formGroup.setErrors({ cdSubmitButton: true }); + }, + () => { + this.submitAction.emit(); + this.bsModalRef.hide(); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-change-event.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-change-event.interface.ts new file mode 100644 index 00000000000..0bac44ec2f4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-change-event.interface.ts @@ -0,0 +1,5 @@ +import { InventoryDeviceFiltersChangeEvent } from '../../inventory/inventory-devices/inventory-device-filters-change-event.interface'; + +export interface DevicesSelectionChangeEvent extends InventoryDeviceFiltersChangeEvent { + type: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-clear-event.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-clear-event.interface.ts new file mode 100644 index 00000000000..58b7c828493 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-clear-event.interface.ts @@ -0,0 +1,6 @@ +import { InventoryDevice } from '../../inventory/inventory-devices/inventory-device.model'; + +export interface DevicesSelectionClearEvent { + type: string; + clearedDevices: InventoryDevice[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.html new file mode 100644 index 00000000000..fc1df4ef849 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.html @@ -0,0 +1,45 @@ + +
+ +
+ + + + +
+ + {{ filter.label }}: {{ filter.formatValue }} + + + + Clear + +
+
+ + +
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss new file mode 100644 index 00000000000..3fb8f6b3848 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss @@ -0,0 +1,3 @@ +.tc_clearSelections { + text-decoration: none; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts new file mode 100644 index 00000000000..f15f3a0382d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts @@ -0,0 +1,133 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; + +import { + configureTestBed, + FixtureHelper, + i18nProviders +} from '../../../../../testing/unit-test-helper'; +import { SharedModule } from '../../../../shared/shared.module'; +import { InventoryDevicesComponent } from '../../inventory/inventory-devices/inventory-devices.component'; +import { OsdDevicesSelectionGroupsComponent } from './osd-devices-selection-groups.component'; + +describe('OsdDevicesSelectionGroupsComponent', () => { + let component: OsdDevicesSelectionGroupsComponent; + let fixture: ComponentFixture; + let fixtureHelper: FixtureHelper; + const devices = [ + { + hostname: 'node0', + uid: '1', + path: 'sda', + sys_api: { + vendor: 'AAA', + model: 'aaa', + size: 1024, + rotational: 'false', + human_readable_size: '1 KB' + }, + available: false, + rejected_reasons: [''], + device_id: 'AAA-aaa-id0', + human_readable_type: 'nvme/ssd', + osd_ids: [] + } + ]; + + const buttonSelector = '.col-sm-9 button'; + const getButton = () => { + const debugElement = fixtureHelper.getElementByCss(buttonSelector); + return debugElement.nativeElement; + }; + const clearTextSelector = '.tc_clearSelections'; + const getClearText = () => { + const debugElement = fixtureHelper.getElementByCss(clearTextSelector); + return debugElement.nativeElement; + }; + + configureTestBed({ + imports: [FormsModule, SharedModule], + providers: [i18nProviders], + declarations: [OsdDevicesSelectionGroupsComponent, InventoryDevicesComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OsdDevicesSelectionGroupsComponent); + fixtureHelper = new FixtureHelper(fixture); + component = fixture.componentInstance; + component.canSelect = true; + }); + + describe('without available devices', () => { + beforeEach(() => { + component.availDevices = []; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display Add button in disabled state', () => { + const button = getButton(); + expect(button).toBeTruthy(); + expect(button.disabled).toBe(true); + expect(button.textContent).toBe('Add'); + }); + + it('should not display devices table', () => { + fixtureHelper.expectElementVisible('cd-inventory-devices', false); + }); + }); + + describe('without devices selected', () => { + beforeEach(() => { + component.availDevices = devices; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display Add button in enabled state', () => { + const button = getButton(); + expect(button).toBeTruthy(); + expect(button.disabled).toBe(false); + expect(button.textContent).toBe('Add'); + }); + + it('should not display devices table', () => { + fixtureHelper.expectElementVisible('cd-inventory-devices', false); + }); + }); + + describe('with devices selected', () => { + beforeEach(() => { + component.availDevices = []; + component.devices = devices; + fixture.detectChanges(); + }); + + it('should display clear link', () => { + const text = getClearText(); + expect(text).toBeTruthy(); + expect(text.textContent).toBe('Clear'); + }); + + it('should display devices table', () => { + fixtureHelper.expectElementVisible('cd-inventory-devices', true); + }); + + it('should clear devices by clicking Clear link', () => { + spyOn(component.cleared, 'emit'); + fixtureHelper.clickElement(clearTextSelector); + fixtureHelper.expectElementVisible('cd-inventory-devices', false); + const event = { + type: undefined, + clearedDevices: devices + }; + expect(component.cleared.emit).toHaveBeenCalledWith(event); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts new file mode 100644 index 00000000000..0fa59d27db8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts @@ -0,0 +1,74 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import * as _ from 'lodash'; + +import { BsModalService, ModalOptions } from 'ngx-bootstrap/modal'; +import { Icons } from '../../../../shared/enum/icons.enum'; +import { InventoryDeviceFiltersChangeEvent } from '../../inventory/inventory-devices/inventory-device-filters-change-event.interface'; +import { InventoryDevice } from '../../inventory/inventory-devices/inventory-device.model'; +import { OsdDevicesSelectionModalComponent } from '../osd-devices-selection-modal/osd-devices-selection-modal.component'; +import { DevicesSelectionChangeEvent } from './devices-selection-change-event.interface'; +import { DevicesSelectionClearEvent } from './devices-selection-clear-event.interface'; + +@Component({ + selector: 'cd-osd-devices-selection-groups', + templateUrl: './osd-devices-selection-groups.component.html', + styleUrls: ['./osd-devices-selection-groups.component.scss'] +}) +export class OsdDevicesSelectionGroupsComponent { + // data, wal, db + @Input() type: string; + + // Data, WAL, DB + @Input() name: string; + + @Input() hostname: string; + + @Input() availDevices: InventoryDevice[]; + + @Input() canSelect: boolean; + + @Output() + selected = new EventEmitter(); + + @Output() + cleared = new EventEmitter(); + + icons = Icons; + devices: InventoryDevice[] = []; + appliedFilters = []; + + constructor(private bsModalService: BsModalService) {} + + showSelectionModal() { + let filterColumns = ['human_readable_type', 'sys_api.vendor', 'sys_api.model', 'sys_api.size']; + if (this.type === 'data') { + filterColumns = ['hostname', ...filterColumns]; + } + const options: ModalOptions = { + class: 'modal-xl', + initialState: { + hostname: this.hostname, + deviceType: this.name, + devices: this.availDevices, + filterColumns: filterColumns + } + }; + const modalRef = this.bsModalService.show(OsdDevicesSelectionModalComponent, options); + modalRef.content.submitAction.subscribe((result: InventoryDeviceFiltersChangeEvent) => { + this.devices = result.filterInDevices; + this.appliedFilters = result.filters; + const event = _.assign({ type: this.type }, result); + this.selected.emit(event); + }); + } + + clearDevices() { + const event = { + type: this.type, + clearedDevices: [...this.devices] + }; + this.devices = []; + this.cleared.emit(event); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.html new file mode 100644 index 00000000000..a543a959257 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.html @@ -0,0 +1,26 @@ + + Add {{ deviceType }} devices + + +
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts new file mode 100644 index 00000000000..d58da46fcd5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts @@ -0,0 +1,87 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { BsModalRef } from 'ngx-bootstrap/modal'; + +import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper'; +import { SharedModule } from '../../../../shared/shared.module'; +import { InventoryDeviceFiltersChangeEvent } from '../../inventory/inventory-devices/inventory-device-filters-change-event.interface'; +import { InventoryDevice } from '../../inventory/inventory-devices/inventory-device.model'; +import { InventoryDevicesComponent } from '../../inventory/inventory-devices/inventory-devices.component'; +import { OsdDevicesSelectionModalComponent } from './osd-devices-selection-modal.component'; + +describe('OsdDevicesSelectionModalComponent', () => { + let component: OsdDevicesSelectionModalComponent; + let fixture: ComponentFixture; + const devices: InventoryDevice[] = [ + { + hostname: 'node0', + uid: '1', + path: 'sda', + sys_api: { + vendor: 'AAA', + model: 'aaa', + size: 1024, + rotational: 'false', + human_readable_size: '1 KB' + }, + available: false, + rejected_reasons: [''], + device_id: 'AAA-aaa-id0', + human_readable_type: 'nvme/ssd', + osd_ids: [] + } + ]; + + const expectSubmitButton = (enabled: boolean) => { + const nativeElement = fixture.debugElement.nativeElement; + const button = nativeElement.querySelector('.modal-footer button'); + expect(button.disabled).toBe(!enabled); + }; + + configureTestBed({ + imports: [FormsModule, SharedModule, ReactiveFormsModule, RouterTestingModule], + providers: [BsModalRef, i18nProviders], + declarations: [OsdDevicesSelectionModalComponent, InventoryDevicesComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OsdDevicesSelectionModalComponent); + component = fixture.componentInstance; + component.devices = devices; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should disable submit button initially', () => { + expectSubmitButton(false); + }); + + it('should enable submit button after filtering some devices', () => { + const event: InventoryDeviceFiltersChangeEvent = { + filters: [ + { + label: 'hostname', + prop: 'hostname', + value: 'node0', + formatValue: 'node0' + }, + { + label: 'size', + prop: 'size', + value: '1024', + formatValue: '1KiB' + } + ], + filterInDevices: devices, + filterOutDevices: [] + }; + component.onFilterChange(event); + fixture.detectChanges(); + expectSubmitButton(true); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.ts new file mode 100644 index 00000000000..080cdec353e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.ts @@ -0,0 +1,77 @@ +import { Component, EventEmitter, Output } from '@angular/core'; + +import * as _ from 'lodash'; +import { BsModalRef } from 'ngx-bootstrap/modal'; + +import { ActionLabelsI18n } from '../../../../shared/constants/app.constants'; +import { Icons } from '../../../../shared/enum/icons.enum'; +import { CdFormBuilder } from '../../../../shared/forms/cd-form-builder'; +import { CdFormGroup } from '../../../../shared/forms/cd-form-group'; +import { InventoryDeviceFiltersChangeEvent } from '../../inventory/inventory-devices/inventory-device-filters-change-event.interface'; +import { InventoryDevice } from '../../inventory/inventory-devices/inventory-device.model'; + +@Component({ + selector: 'cd-osd-devices-selection-modal', + templateUrl: './osd-devices-selection-modal.component.html', + styleUrls: ['./osd-devices-selection-modal.component.scss'] +}) +export class OsdDevicesSelectionModalComponent { + @Output() + submitAction = new EventEmitter(); + + icons = Icons; + filterColumns: string[] = []; + + hostname: string; + deviceType: string; + formGroup: CdFormGroup; + action: string; + + devices: InventoryDevice[] = []; + canSubmit = false; + filters = []; + filterInDevices: InventoryDevice[] = []; + filterOutDevices: InventoryDevice[] = []; + + isFiltered = false; + + constructor( + private formBuilder: CdFormBuilder, + public bsModalRef: BsModalRef, + public actionLabels: ActionLabelsI18n + ) { + this.action = actionLabels.ADD; + this.createForm(); + } + + createForm() { + this.formGroup = this.formBuilder.group({}); + } + + onFilterChange(event: InventoryDeviceFiltersChangeEvent) { + this.canSubmit = false; + this.filters = event.filters; + if (_.isEmpty(event.filters)) { + // filters are cleared + this.filterInDevices = []; + this.filterOutDevices = []; + } else { + // at least one filter is required (except hostname) + const filters = this.filters.filter((filter) => { + return filter.prop !== 'hostname'; + }); + this.canSubmit = !_.isEmpty(filters); + this.filterInDevices = event.filterInDevices; + this.filterOutDevices = event.filterOutDevices; + } + } + + onSubmit() { + this.submitAction.emit({ + filters: this.filters, + filterInDevices: this.filterInDevices, + filterOutDevices: this.filterOutDevices + }); + this.bsModalRef.hide(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/drive-group.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/drive-group.model.ts new file mode 100644 index 00000000000..8201eed4c3a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/drive-group.model.ts @@ -0,0 +1,88 @@ +import { FormatterService } from '../../../../shared/services/formatter.service'; +import { InventoryDeviceAppliedFilter } from '../../inventory/inventory-devices/inventory-device-applied-filters.interface'; + +export class DriveGroup { + // DriveGroupSpec object. + spec = {}; + + // Map from filter column prop to device selection attribute name + private deviceSelectionAttrs: { + [key: string]: { + name: string; + formatter?: Function; + }; + }; + + private formatterService: FormatterService; + + constructor() { + this.formatterService = new FormatterService(); + this.deviceSelectionAttrs = { + 'sys_api.vendor': { + name: 'vendor' + }, + 'sys_api.model': { + name: 'model' + }, + device_id: { + name: 'device_id' + }, + human_readable_type: { + name: 'rotational', + formatter: (value: string) => { + return value.toLowerCase() === 'hdd'; + } + }, + 'sys_api.size': { + name: 'size', + formatter: (value: string) => { + return this.formatterService + .format_number(value, 1024, ['B', 'KB', 'MB', 'GB', 'TB', 'PB']) + .replace(' ', ''); + } + } + }; + } + + reset() { + this.spec = {}; + } + + setHostPattern(pattern: string) { + this.spec['host_pattern'] = pattern; + } + + setDeviceSelection(type: string, appliedFilters: InventoryDeviceAppliedFilter[]) { + const key = `${type}_devices`; + this.spec[key] = {}; + appliedFilters.forEach((filter) => { + const attr = this.deviceSelectionAttrs[filter.prop]; + if (attr) { + const name = attr.name; + this.spec[key][name] = attr.formatter ? attr.formatter(filter.value) : filter.value; + } + }); + } + + clearDeviceSelection(type: string) { + const key = `${type}_devices`; + delete this.spec[key]; + } + + setSlots(type: string, slots: number) { + const key = `${type}_slots`; + if (slots === 0) { + delete this.spec[key]; + } else { + this.spec[key] = slots; + } + } + + setFeature(feature: string, enabled: boolean) { + if (enabled) { + this.spec[feature] = true; + } else { + delete this.spec[feature]; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-feature.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-feature.interface.ts new file mode 100644 index 00000000000..8c9dc452e23 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-feature.interface.ts @@ -0,0 +1,4 @@ +export interface OsdFeature { + desc: string; + key?: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html new file mode 100644 index 00000000000..4b0a0c158c5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html @@ -0,0 +1,142 @@ +Loading... +Please consult the + documentation on how to + configure and enable the orchestrator functionality. +
+
+
+
{{ action | titlecase }} {{ resource | upperFirst }}
+
+
+ + +
+ + +
+ Shared devices + + + + + + +
+ +
+ + Value should be greater than or equal to 0 +
+
+ + + + + + +
+ +
+ + Value should be greater than or equal to 0 +
+
+
+ + +
+ Configuration + + +
+ +
+
+ + +
+
+
+
+
+ +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts new file mode 100644 index 00000000000..ee2648ac900 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts @@ -0,0 +1,224 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { BehaviorSubject, of } from 'rxjs'; + +import { + configureTestBed, + FixtureHelper, + FormHelper, + i18nProviders +} from '../../../../../testing/unit-test-helper'; +import { OrchestratorService } from '../../../../shared/api/orchestrator.service'; +import { CdFormGroup } from '../../../../shared/forms/cd-form-group'; +import { SummaryService } from '../../../../shared/services/summary.service'; +import { SharedModule } from '../../../../shared/shared.module'; +import { InventoryDevice } from '../../inventory/inventory-devices/inventory-device.model'; +import { InventoryDevicesComponent } from '../../inventory/inventory-devices/inventory-devices.component'; +import { DevicesSelectionChangeEvent } from '../osd-devices-selection-groups/devices-selection-change-event.interface'; +import { DevicesSelectionClearEvent } from '../osd-devices-selection-groups/devices-selection-clear-event.interface'; +import { OsdDevicesSelectionGroupsComponent } from '../osd-devices-selection-groups/osd-devices-selection-groups.component'; +import { OsdFormComponent } from './osd-form.component'; + +describe('OsdFormComponent', () => { + let form: CdFormGroup; + let component: OsdFormComponent; + let formHelper: FormHelper; + let fixture: ComponentFixture; + let fixtureHelper: FixtureHelper; + let orchService: OrchestratorService; + let summaryService: SummaryService; + const devices: InventoryDevice[] = [ + { + hostname: 'node0', + uid: '1', + + path: '/dev/sda', + sys_api: { + vendor: 'VENDOR', + model: 'MODEL', + size: 1024, + rotational: 'false', + human_readable_size: '1 KB' + }, + available: true, + rejected_reasons: [''], + device_id: 'VENDOR-MODEL-ID', + human_readable_type: 'nvme/ssd', + osd_ids: [] + } + ]; + + const expectPreviewButton = (enabled: boolean) => { + const debugElement = fixtureHelper.getElementByCss('.card-footer button'); + expect(debugElement.nativeElement.disabled).toBe(!enabled); + }; + + const selectDevices = (type: string) => { + const event: DevicesSelectionChangeEvent = { + type: type, + filters: [], + filterInDevices: devices, + filterOutDevices: [] + }; + component.onDevicesSelected(event); + if (type === 'data') { + component.dataDeviceSelectionGroups.devices = devices; + } else if (type === 'wal') { + component.walDeviceSelectionGroups.devices = devices; + } else if (type === 'db') { + component.dbDeviceSelectionGroups.devices = devices; + } + fixture.detectChanges(); + }; + + const clearDevices = (type: string) => { + const event: DevicesSelectionClearEvent = { + type: type, + clearedDevices: [] + }; + component.onDevicesCleared(event); + fixture.detectChanges(); + }; + + const features = ['encrypted']; + const checkFeatures = (enabled: boolean) => { + for (const feature of features) { + const element = fixtureHelper.getElementByCss(`#${feature}`).nativeElement; + expect(element.disabled).toBe(!enabled); + expect(element.checked).toBe(false); + } + }; + + configureTestBed({ + imports: [ + HttpClientTestingModule, + FormsModule, + SharedModule, + RouterTestingModule, + ReactiveFormsModule + ], + providers: [i18nProviders], + declarations: [OsdFormComponent, OsdDevicesSelectionGroupsComponent, InventoryDevicesComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OsdFormComponent); + fixtureHelper = new FixtureHelper(fixture); + component = fixture.componentInstance; + form = component.form; + formHelper = new FormHelper(form); + orchService = TestBed.get(OrchestratorService); + summaryService = TestBed.get(SummaryService); + summaryService['summaryDataSource'] = new BehaviorSubject(null); + summaryService['summaryData$'] = summaryService['summaryDataSource'].asObservable(); + summaryService['summaryDataSource'].next({ version: 'master' }); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('without orchestrator', () => { + beforeEach(() => { + spyOn(orchService, 'status').and.returnValue(of({ available: false })); + spyOn(orchService, 'inventoryDeviceList').and.callThrough(); + fixture.detectChanges(); + }); + + it('should display info panel to document', () => { + fixtureHelper.expectElementVisible('cd-alert-panel', true); + fixtureHelper.expectElementVisible('.col-sm-10 form', false); + }); + + it('should not call inventoryDeviceList', () => { + expect(orchService.inventoryDeviceList).not.toHaveBeenCalled(); + }); + }); + + describe('with orchestrator', () => { + beforeEach(() => { + spyOn(orchService, 'status').and.returnValue(of({ available: true })); + spyOn(orchService, 'inventoryDeviceList').and.returnValue(of([])); + fixture.detectChanges(); + }); + + it('should display form', () => { + fixtureHelper.expectElementVisible('cd-alert-panel', false); + fixtureHelper.expectElementVisible('.col-sm-10 form', true); + }); + + describe('without data devices selected', () => { + it('should disable preview button', () => { + expectPreviewButton(false); + }); + + it('should not display shared devices slots', () => { + fixtureHelper.expectElementVisible('#walSlots', false); + fixtureHelper.expectElementVisible('#dbSlots', false); + }); + + it('should disable the checkboxes', () => { + checkFeatures(false); + }); + }); + + describe('with data devices selected', () => { + beforeEach(() => { + selectDevices('data'); + }); + + it('should enable preview button', () => { + expectPreviewButton(true); + }); + + it('should not display shared devices slots', () => { + fixtureHelper.expectElementVisible('#walSlots', false); + fixtureHelper.expectElementVisible('#dbSlots', false); + }); + + it('should enable the checkboxes', () => { + checkFeatures(true); + }); + + it('should disable the checkboxes after clearing data devices', () => { + clearDevices('data'); + checkFeatures(false); + }); + + describe('with shared devices selected', () => { + beforeEach(() => { + selectDevices('wal'); + selectDevices('db'); + }); + + it('should display slots', () => { + fixtureHelper.expectElementVisible('#walSlots', true); + fixtureHelper.expectElementVisible('#dbSlots', true); + }); + + it('validate slots', () => { + for (const control of ['walSlots', 'dbSlots']) { + formHelper.expectValid(control); + formHelper.expectValidChange(control, 1); + formHelper.expectErrorChange(control, -1, 'min'); + } + }); + + describe('test clearing data devices', () => { + beforeEach(() => { + clearDevices('data'); + }); + + it('should not display shared devices slots and should disable checkboxes', () => { + fixtureHelper.expectElementVisible('#walSlots', false); + fixtureHelper.expectElementVisible('#dbSlots', false); + checkFeatures(false); + }); + }); + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts new file mode 100644 index 00000000000..0bc01793db5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts @@ -0,0 +1,245 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { I18n } from '@ngx-translate/i18n-polyfill'; +import * as _ from 'lodash'; +import { BsModalService, ModalOptions } from 'ngx-bootstrap/modal'; + +import { OrchestratorService } from '../../../../shared/api/orchestrator.service'; +import { SubmitButtonComponent } from '../../../../shared/components/submit-button/submit-button.component'; +import { ActionLabelsI18n } from '../../../../shared/constants/app.constants'; +import { Icons } from '../../../../shared/enum/icons.enum'; +import { CdFormGroup } from '../../../../shared/forms/cd-form-group'; +import { CdTableColumn } from '../../../../shared/models/cd-table-column'; +import { CephReleaseNamePipe } from '../../../../shared/pipes/ceph-release-name.pipe'; +import { SummaryService } from '../../../../shared/services/summary.service'; +import { InventoryDevice } from '../../inventory/inventory-devices/inventory-device.model'; +import { OsdCreationPreviewModalComponent } from '../osd-creation-preview-modal/osd-creation-preview-modal.component'; +import { DevicesSelectionChangeEvent } from '../osd-devices-selection-groups/devices-selection-change-event.interface'; +import { DevicesSelectionClearEvent } from '../osd-devices-selection-groups/devices-selection-clear-event.interface'; +import { OsdDevicesSelectionGroupsComponent } from '../osd-devices-selection-groups/osd-devices-selection-groups.component'; +import { DriveGroup } from './drive-group.model'; +import { OsdFeature } from './osd-feature.interface'; + +@Component({ + selector: 'cd-osd-form', + templateUrl: './osd-form.component.html', + styleUrls: ['./osd-form.component.scss'] +}) +export class OsdFormComponent implements OnInit { + @ViewChild('dataDeviceSelectionGroups', { static: false }) + dataDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent; + + @ViewChild('walDeviceSelectionGroups', { static: false }) + walDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent; + + @ViewChild('dbDeviceSelectionGroups', { static: false }) + dbDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent; + + @ViewChild('previewButton', { static: false }) + previewButton: SubmitButtonComponent; + + icons = Icons; + + form: CdFormGroup; + columns: Array = []; + + loading = false; + allDevices: InventoryDevice[] = []; + + availDevices: InventoryDevice[] = []; + dataDeviceFilters = []; + dbDeviceFilters = []; + walDeviceFilters = []; + hostname = ''; + driveGroup = new DriveGroup(); + + action: string; + resource: string; + + features: { [key: string]: OsdFeature }; + featureList: OsdFeature[] = []; + + checkingOrchestrator = true; + orchestratorExist = false; + docsUrl: string; + + constructor( + public actionLabels: ActionLabelsI18n, + private i18n: I18n, + private orchService: OrchestratorService, + private router: Router, + private bsModalService: BsModalService, + private summaryService: SummaryService, + private cephReleaseNamePipe: CephReleaseNamePipe + ) { + this.resource = this.i18n('OSDs'); + this.action = this.actionLabels.CREATE; + this.features = { + encrypted: { + key: 'encrypted', + desc: this.i18n('Encryption') + } + }; + this.featureList = _.map(this.features, (o, key) => Object.assign(o, { key: key })); + this.createForm(); + } + + ngOnInit() { + const subs = this.summaryService.subscribe((summary: any) => { + if (!summary) { + return; + } + + const releaseName = this.cephReleaseNamePipe.transform(summary.version); + this.docsUrl = `http://docs.ceph.com/docs/${releaseName}/mgr/orchestrator_cli/`; + + setTimeout(() => { + subs.unsubscribe(); + }, 0); + }); + + this.orchService.status().subscribe((data: { available: boolean }) => { + this.orchestratorExist = data.available; + this.checkingOrchestrator = false; + if (this.orchestratorExist) { + this.getDataDevices(); + } + }); + + this.form.get('walSlots').valueChanges.subscribe((value) => this.setSlots('wal', value)); + this.form.get('dbSlots').valueChanges.subscribe((value) => this.setSlots('db', value)); + _.each(this.features, (feature) => { + this.form + .get('features') + .get(feature.key) + .valueChanges.subscribe((value) => this.featureFormUpdate(feature.key, value)); + }); + } + + createForm() { + this.form = new CdFormGroup({ + walSlots: new FormControl(0, { + updateOn: 'blur', + validators: [Validators.min(0)] + }), + dbSlots: new FormControl(0, { + updateOn: 'blur', + validators: [Validators.min(0)] + }), + features: new CdFormGroup( + this.featureList.reduce((acc, e) => { + // disable initially because no data devices are selected + acc[e.key] = new FormControl({ value: false, disabled: true }); + return acc; + }, {}) + ) + }); + } + + getDataDevices() { + if (this.loading) { + return; + } + this.loading = true; + this.orchService.inventoryDeviceList().subscribe( + (devices: InventoryDevice[]) => { + this.allDevices = _.filter(devices, 'available'); + this.availDevices = [...devices]; + this.loading = false; + }, + () => { + this.allDevices = []; + this.availDevices = []; + this.loading = false; + } + ); + } + + setSlots(type: string, slots: number) { + if (typeof slots !== 'number') { + return; + } + if (slots >= 0) { + this.driveGroup.setSlots(type, slots); + } + } + + featureFormUpdate(key: string, checked: boolean) { + this.driveGroup.setFeature(key, checked); + } + + enableFeatures() { + this.featureList.forEach((feature) => { + this.form.get(feature.key).enable({ emitEvent: false }); + }); + } + + disableFeatures() { + this.featureList.forEach((feature) => { + const control = this.form.get(feature.key); + control.disable({ emitEvent: false }); + control.setValue(false, { emitEvent: false }); + }); + } + + onDevicesSelected(event: DevicesSelectionChangeEvent) { + this.availDevices = event.filterOutDevices; + + if (event.type === 'data') { + // If user selects data devices for a single host, make only remaining devices on + // that host as available. + const hostnameFilter = _.find(event.filters, { prop: 'hostname' }); + if (hostnameFilter) { + this.hostname = hostnameFilter.value; + this.availDevices = event.filterOutDevices.filter((device: InventoryDevice) => { + return device.hostname === this.hostname; + }); + this.driveGroup.setHostPattern(this.hostname); + } else { + this.driveGroup.setHostPattern('*'); + } + this.enableFeatures(); + } + this.driveGroup.setDeviceSelection(event.type, event.filters); + } + + onDevicesCleared(event: DevicesSelectionClearEvent) { + if (event.type === 'data') { + this.availDevices = [...this.allDevices]; + this.walDeviceSelectionGroups.devices = []; + this.dbDeviceSelectionGroups.devices = []; + this.disableFeatures(); + this.driveGroup.reset(); + this.form.get('walSlots').setValue(0, { emitEvent: false }); + this.form.get('dbSlots').setValue(0, { emitEvent: false }); + } else { + this.availDevices = [...this.availDevices, ...event.clearedDevices]; + this.driveGroup.clearDeviceSelection(event.type); + const slotControlName = `${event.type}Slots`; + this.form.get(slotControlName).setValue(0, { emitEvent: false }); + } + } + + submit() { + let allHosts = []; + if (this.hostname === '') { + // wildcard * to match all hosts, provide hosts we can see + allHosts = _.sortedUniq(_.map(this.allDevices, 'hostname').sort()); + } else { + allHosts = [this.hostname]; + } + const options: ModalOptions = { + initialState: { + driveGroup: this.driveGroup, + allHosts: allHosts + } + }; + const modalRef = this.bsModalService.show(OsdCreationPreviewModalComponent, options); + modalRef.content.submitAction.subscribe(() => { + this.router.navigate(['/osd']); + }); + this.previewButton.loading = false; + } +} 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 b42dbf54a1b..ac6ce511b90 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 @@ -239,6 +239,7 @@ describe('OsdListComponent', () => { expect(tableActions).toEqual({ 'create,update,delete': { actions: [ + 'Create', 'Scrub', 'Deep Scrub', 'Reweight', @@ -249,22 +250,25 @@ describe('OsdListComponent', () => { 'Purge', 'Destroy' ], - primary: { multiple: 'Scrub', executing: 'Scrub', single: 'Scrub', no: 'Scrub' } + primary: { multiple: 'Scrub', executing: 'Scrub', single: 'Scrub', no: 'Create' } }, 'create,update': { - actions: ['Scrub', 'Deep Scrub', 'Reweight', 'Mark Out', 'Mark In', 'Mark Down'], - primary: { multiple: 'Scrub', executing: 'Scrub', single: 'Scrub', no: 'Scrub' } + actions: ['Create', 'Scrub', 'Deep Scrub', 'Reweight', 'Mark Out', 'Mark In', 'Mark Down'], + primary: { multiple: 'Scrub', executing: 'Scrub', single: 'Scrub', no: 'Create' } }, 'create,delete': { - actions: ['Mark Lost', 'Purge', 'Destroy'], + actions: ['Create', 'Mark Lost', 'Purge', 'Destroy'], primary: { - multiple: 'Mark Lost', + multiple: 'Create', executing: 'Mark Lost', single: 'Mark Lost', - no: 'Mark Lost' + no: 'Create' } }, - create: { actions: [], primary: { multiple: '', executing: '', single: '', no: '' } }, + create: { + actions: ['Create'], + primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' } + }, 'update,delete': { actions: [ 'Scrub', @@ -312,13 +316,16 @@ describe('OsdListComponent', () => { fixture.detectChanges(); })); - it('has all menu entries disabled', () => { + it('has all menu entries disabled except create', () => { const tableActionElement = fixture.debugElement.query(By.directive(TableActionsComponent)); const toClassName = TestBed.get(TableActionsComponent).toClassName; const getActionClasses = (action: CdTableAction) => tableActionElement.query(By.css(`.${toClassName(action.name)} .dropdown-item`)).classes; component.tableActions.forEach((action) => { + if (action.name === 'Create') { + return; + } expect(getActionClasses(action).disabled).toBe(true); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts index d8d0e2fd78a..42f6775634d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts @@ -18,16 +18,20 @@ import { CdTableSelection } from '../../../../shared/models/cd-table-selection'; import { Permissions } from '../../../../shared/models/permissions'; import { DimlessBinaryPipe } from '../../../../shared/pipes/dimless-binary.pipe'; import { AuthStorageService } from '../../../../shared/services/auth-storage.service'; +import { URLBuilderService } from '../../../../shared/services/url-builder.service'; import { OsdFlagsModalComponent } from '../osd-flags-modal/osd-flags-modal.component'; import { OsdPgScrubModalComponent } from '../osd-pg-scrub-modal/osd-pg-scrub-modal.component'; import { OsdRecvSpeedModalComponent } from '../osd-recv-speed-modal/osd-recv-speed-modal.component'; import { OsdReweightModalComponent } from '../osd-reweight-modal/osd-reweight-modal.component'; import { OsdScrubModalComponent } from '../osd-scrub-modal/osd-scrub-modal.component'; +const BASE_URL = 'osd'; + @Component({ selector: 'cd-osd-list', templateUrl: './osd-list.component.html', - styleUrls: ['./osd-list.component.scss'] + styleUrls: ['./osd-list.component.scss'], + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }] }) export class OsdListComponent implements OnInit { @ViewChild('statusColor', { static: true }) @@ -73,16 +77,25 @@ export class OsdListComponent implements OnInit { private dimlessBinaryPipe: DimlessBinaryPipe, private modalService: BsModalService, private i18n: I18n, + private urlBuilder: URLBuilderService, public actionLabels: ActionLabelsI18n ) { this.permissions = this.authStorageService.getPermissions(); this.tableActions = [ + { + name: this.actionLabels.CREATE, + permission: 'create', + icon: Icons.add, + routerLink: () => this.urlBuilder.getCreate(), + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + }, { name: this.actionLabels.SCRUB, permission: 'update', icon: Icons.analyse, click: () => this.scrubAction(false), - disable: () => !this.hasOsdSelected + disable: () => !this.hasOsdSelected, + canBePrimary: (selection: CdTableSelection) => selection.hasSelection }, { name: this.actionLabels.DEEP_SCRUB, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts index 692b28b5839..5ee096fd4cf 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts @@ -18,7 +18,10 @@ export class ServicesComponent implements OnChanges, OnInit { @ViewChild(TableComponent, { static: false }) table: TableComponent; - @Input() hostname = ''; + @Input() hostname: string; + + // Do not display these columns + @Input() hiddenColumns: string[] = []; checkingOrchestrator = true; orchestratorExist = false; @@ -36,7 +39,12 @@ export class ServicesComponent implements OnChanges, OnInit { ) {} ngOnInit() { - this.columns = [ + const columns = [ + { + name: this.i18n('Hostname'), + prop: 'nodename', + flexGrow: 2 + }, { name: this.i18n('Service type'), prop: 'service_type', @@ -84,14 +92,9 @@ export class ServicesComponent implements OnChanges, OnInit { } ]; - if (!this.hostname) { - const hostnameColumn = { - name: this.i18n('Hostname'), - prop: 'nodename', - flexGrow: 2 - }; - this.columns.splice(0, 0, hostnameColumn); - } + this.columns = columns.filter((col: any) => { + return !this.hiddenColumns.includes(col.prop); + }); // duplicated code with grafana const subs = this.summaryService.subscribe((summary: any) => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts index 99320224483..06d87a17706 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts @@ -1,19 +1,73 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; import { configureTestBed, i18nProviders } from '../../../testing/unit-test-helper'; import { OrchestratorService } from './orchestrator.service'; describe('OrchestratorService', () => { - beforeEach(() => TestBed.configureTestingModule({})); + let service: OrchestratorService; + let httpTesting: HttpTestingController; configureTestBed({ providers: [OrchestratorService, i18nProviders], imports: [HttpClientTestingModule] }); + beforeEach(() => { + service = TestBed.get(OrchestratorService); + httpTesting = TestBed.get(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + it('should be created', () => { - const service: OrchestratorService = TestBed.get(OrchestratorService); expect(service).toBeTruthy(); }); + + it('should call status', () => { + service.status().subscribe(); + const req = httpTesting.expectOne(service.statusURL); + expect(req.request.method).toBe('GET'); + }); + + it('should call inventoryList', () => { + service.inventoryList().subscribe(); + const req = httpTesting.expectOne(service.inventoryURL); + expect(req.request.method).toBe('GET'); + }); + + it('should call inventoryList with a host', () => { + const host = 'host0'; + service.inventoryList(host).subscribe(); + const req = httpTesting.expectOne(`${service.inventoryURL}?hostname=${host}`); + expect(req.request.method).toBe('GET'); + }); + + it('should call serviceList', () => { + service.serviceList().subscribe(); + const req = httpTesting.expectOne(service.serviceURL); + expect(req.request.method).toBe('GET'); + }); + + it('should call serviceList with a host', () => { + const host = 'host0'; + service.serviceList(host).subscribe(); + const req = httpTesting.expectOne(`${service.serviceURL}?hostname=${host}`); + expect(req.request.method).toBe('GET'); + }); + + it('should call osdCreate', () => { + const data = { + drive_group: { + host_pattern: '*' + }, + all_hosts: ['a', 'b'] + }; + service.osdCreate(data['drive_group'], data['all_hosts']).subscribe(); + const req = httpTesting.expectOne(service.osdURL); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(data); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts index 7123e7258e8..b3bbfc5db25 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts @@ -1,5 +1,12 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; + +import * as _ from 'lodash'; +import { Observable, of as observableOf } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; + +import { InventoryDevice } from '../../ceph/cluster/inventory/inventory-devices/inventory-device.model'; +import { InventoryNode } from '../../ceph/cluster/inventory/inventory-node.model'; import { ApiModule } from './api.module'; @Injectable({ @@ -9,6 +16,7 @@ export class OrchestratorService { statusURL = 'api/orchestrator/status'; inventoryURL = 'api/orchestrator/inventory'; serviceURL = 'api/orchestrator/service'; + osdURL = 'api/orchestrator/osd'; constructor(private http: HttpClient) {} @@ -16,13 +24,38 @@ export class OrchestratorService { return this.http.get(this.statusURL); } - inventoryList(hostname: string) { + inventoryList(hostname?: string): Observable { const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {}; - return this.http.get(this.inventoryURL, options); + return this.http.get(this.inventoryURL, options); + } + + inventoryDeviceList(hostname?: string): Observable { + return this.inventoryList(hostname).pipe( + mergeMap((nodes: InventoryNode[]) => { + const devices = _.flatMap(nodes, (node) => { + return node.devices.map((device) => { + device.hostname = node.name; + device.uid = device.device_id ? device.device_id : `${device.hostname}-${device.path}`; + return device; + }); + }); + return observableOf(devices); + }) + ); } - serviceList(hostname: string) { + serviceList(hostname?: string) { const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {}; return this.http.get(this.serviceURL, options); } + + osdCreate(driveGroup: {}, all_hosts: string[]) { + const request = { + drive_group: driveGroup + }; + if (!_.isEmpty(all_hosts)) { + request['all_hosts'] = all_hosts; + } + return this.http.post(this.osdURL, request, { observe: 'response' }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 874d7dc7176..8e2d0c67eed 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -57,6 +57,7 @@ export enum Icons { leftArrowDouble = 'fa fa-angle-double-left', // Left facing Double angle rightArrowDouble = 'fa fa-angle-double-right', // Left facing Double angle flag = 'fa fa-flag', // OSD configuration + clearFilters = 'fa fa-window-close', // Clear filters, solid x /* Icons for special effect */ large = 'fa fa-lg', // icon becomes 33% larger diff --git a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts index 20605b028f9..facea81c721 100644 --- a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts +++ b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts @@ -242,15 +242,34 @@ export class FixtureHelper { this.fixture.detectChanges(); } + selectElement(css: string, value: string) { + const nativeElement = this.getElementByCss(css).nativeElement; + nativeElement.value = value; + nativeElement.dispatchEvent(new Event('change')); + this.fixture.detectChanges(); + } + getText(css: string) { const e = this.getElementByCss(css); return e ? e.nativeElement.textContent.trim() : null; } + getTextAll(css: string) { + const elements = this.getElementByCssAll(css); + return elements.map((element) => { + return element ? element.nativeElement.textContent.trim() : null; + }); + } + getElementByCss(css: string) { this.fixture.detectChanges(); return this.fixture.debugElement.query(By.css(css)); } + + getElementByCssAll(css: string) { + this.fixture.detectChanges(); + return this.fixture.debugElement.queryAll(By.css(css)); + } } export class PrometheusHelper {