});
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);
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';
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',
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';
OsdReweightModalComponent,
OsdPgScrubModalComponent,
OsdReweightModalComponent,
- SilenceMatcherModalComponent
+ SilenceMatcherModalComponent,
+ OsdDevicesSelectionModalComponent,
+ OsdCreationPreviewModalComponent
],
imports: [
CommonModule,
ServicesComponent,
InventoryComponent,
HostFormComponent,
- OsdSmartListComponent
+ OsdSmartListComponent,
+ OsdFormComponent,
+ OsdDevicesSelectionModalComponent,
+ InventoryDevicesComponent,
+ OsdDevicesSelectionGroupsComponent,
+ OsdCreationPreviewModalComponent
]
})
export class ClusterModule {}
<tab i18n-heading
heading="Services"
*ngIf="permissions.hosts.read">
- <cd-services [hostname]="selection.first()['hostname']">
+ <cd-services
+ [hostname]="selection.first()['hostname']"
+ [hiddenColumns]="['nodename']">
</cd-services>
</tab>
<tab i18n-heading
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { of } from 'rxjs';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation';
import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
import { TabsetComponent, TabsModule } from 'ngx-bootstrap/tabs';
-
-import { RouterTestingModule } from '@angular/router/testing';
+import { of } from 'rxjs';
import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper';
import { CoreModule } from '../../../../core/core.module';
import { OrchestratorService } from '../../../../shared/api/orchestrator.service';
HttpClientTestingModule,
TabsModule.forRoot(),
BsDropdownModule.forRoot(),
+ NgBootstrapFormValidationModule.forRoot(),
RouterTestingModule,
CephModule,
CoreModule
});
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();
});
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';
--- /dev/null
+export interface InventoryDeviceAppliedFilter {
+ label: string;
+ prop: string;
+ value: string;
+ formatValue: string;
+}
--- /dev/null
+import { PipeTransform } from '@angular/core';
+
+export interface InventoryDeviceFilter {
+ label: string;
+ prop: string;
+ initValue: string;
+ value: string;
+ options: {
+ value: string;
+ formatValue: string;
+ }[];
+ pipe?: PipeTransform;
+}
--- /dev/null
+import { InventoryDeviceAppliedFilter } from './inventory-device-applied-filters.interface';
+import { InventoryDevice } from './inventory-device.model';
+
+export interface InventoryDeviceFiltersChangeEvent {
+ filters: InventoryDeviceAppliedFilter[];
+ filterInDevices: InventoryDevice[];
+ filterOutDevices: InventoryDevice[];
+}
--- /dev/null
+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[];
+}
--- /dev/null
+<cd-table [data]="filterInDevices"
+ [columns]="columns"
+ identifier="uid"
+ [forceIdentifier]="true"
+ [selectionType]="selectionType"
+ columnMode="flex"
+ [autoReload]="false">
+ <div class="table-filters form-inline"
+ *ngIf="filters.length !== 0">
+ <div class="form-group filter tc_filter"
+ *ngFor="let filter of filters">
+ <label class="col-form-label"><span>{{ filter.label }}</span><span>: </span></label>
+ <select class="custom-select"
+ [(ngModel)]="filter.value"
+ [ngModelOptions]="{standalone: true}"
+ (ngModelChange)="onFilterChange()"
+ [disabled]="filter.disabled">
+ <option *ngFor="let opt of filter.options"
+ [value]="opt.value">{{ opt.formatValue }}</option>
+ </select>
+ </div>
+ <div class="widget-toolbar tc_refreshBtn"
+ *ngIf="filters.length !== 0">
+ <button type="button"
+ title="Reset filters"
+ class="btn btn-light"
+ (click)="onFilterReset()">
+ <span [ngClass]="[icons.stack]">
+ <i [ngClass]="[icons.filter, icons.stack2x]"></i>
+ <i [ngClass]="[icons.destroy, icons.stack1x]"></i>
+ </span>
+ </button>
+ </div>
+ </div>
+</cd-table>
+
+<ng-template #osds
+ let-value="value">
+ <span *ngFor="let osdId of value; last as last">
+ <span class="badge badge-dark">osd.{{ osdId }}</span>
+ <span *ngIf="!last"> </span>
+ </span>
+</ng-template>
--- /dev/null
+.filter {
+ padding-right: 8px;
+}
+
+.fa-stack {
+ font-size: 0.79rem;
+
+ .fa-stack-1x {
+ margin-left: 8px;
+ margin-top: 5px;
+ }
+}
--- /dev/null
+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<InventoryDevicesComponent>;
+ 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);
+ });
+ });
+});
--- /dev/null
+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<any>;
+
+ // 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<InventoryDeviceFiltersChangeEvent>();
+
+ filterInDevices: InventoryDevice[] = [];
+ filterOutDevices: InventoryDevice[] = [];
+
+ icons = Icons;
+ columns: Array<CdTableColumn> = [];
+ 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
+ });
+ }
+}
--- /dev/null
+import { InventoryDevice } from './inventory-devices/inventory-device.model';
+
+export class InventoryNode {
+ name: string;
+ devices: InventoryDevice[];
+}
<legend i18n>Devices</legend>
<div class="row">
<div class="col-md-12">
- <cd-table [data]="devices"
- [columns]="columns"
- identifier="uid"
- forceIdentifier="true"
- columnMode="flex"
- (fetchData)="getInventory($event)"
- selectionType="single">
- </cd-table>
+ <cd-inventory-devices [devices]="devices"
+ [hiddenColumns]="hostname === undefined ? [] : ['hostname']"
+ selectionType="single">
+ </cd-inventory-devices>
</div>
</div>
</ng-container>
-
-<ng-template #osds
- let-value="value">
- <span *ngFor="let osdId of value; last as last">
- <span class="badge badge-dark">osd.{{ osdId }}</span>
- <span *ngIf="!last"> </span>
- </span>
-</ng-template>
-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<InventoryComponent>;
- 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');
+ });
});
});
-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',
styleUrls: ['./inventory.component.scss']
})
export class InventoryComponent implements OnChanges, OnInit {
- @ViewChild(TableComponent, { static: false })
- table: TableComponent;
- @ViewChild('osds', { static: true })
- osds: TemplateRef<any>;
+ // 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<CdTableColumn> = [];
- devices: Array<Device> = [];
+ devices: Array<InventoryDevice> = [];
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) {
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;
}
);
}
+++ /dev/null
-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[];
-}
--- /dev/null
+<cd-modal [modalRef]="bsModalRef">
+ <ng-container class="modal-title"
+ i18n>OSD creation preview</ng-container>
+
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="formGroup"
+ novalidate>
+ <div class="modal-body">
+ <h3>Drive Group</h3>
+ <pre>{{ driveGroup.spec | json}}</pre>
+ </div>
+ <div class="modal-footer">
+ <cd-submit-button (submitAction)="onSubmit()"
+ [form]="formGroup">{{ action | titlecase }}</cd-submit-button>
+ <cd-back-button [back]="bsModalRef.hide"></cd-back-button>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
--- /dev/null
+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<OsdCreationPreviewModalComponent>;
+
+ 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();
+ });
+});
--- /dev/null
+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();
+ }
+ );
+ }
+}
--- /dev/null
+import { InventoryDeviceFiltersChangeEvent } from '../../inventory/inventory-devices/inventory-device-filters-change-event.interface';
+
+export interface DevicesSelectionChangeEvent extends InventoryDeviceFiltersChangeEvent {
+ type: string;
+}
--- /dev/null
+import { InventoryDevice } from '../../inventory/inventory-devices/inventory-device.model';
+
+export interface DevicesSelectionClearEvent {
+ type: string;
+ clearedDevices: InventoryDevice[];
+}
--- /dev/null
+<!-- button -->
+<div class="form-group row">
+ <label class="col-sm-3 col-form-label"
+ for="createDeleteButton">
+ <ng-container i18n>{{ name }} devices</ng-container>
+ <cd-helper>
+ <span i18n
+ *ngIf="type === 'data'">The primary storage devices. These devices contain all OSD data.</span>
+ <span i18n
+ *ngIf="type === 'wal'">Write-Ahead-Log devices. These devices are used for BlueStore’s internal journal. It is only useful to use a WAL device if the device is faster than the primary device (e.g. NVME devices or SSDs). If there is only a small amount of fast storage available (e.g., less than a gigabyte), we recommend using it as a WAL device.</span>
+ <span i18n
+ *ngIf="type === 'db'">DB devices can be used for storing BlueStore’s internal metadata. It is only helpful to provision a DB device if it is faster than the primary device (e.g. NVME devices or SSD).</span>
+ </cd-helper>
+ </label>
+ <div class="col-sm-9">
+ <ng-container *ngIf="devices.length === 0; else blockClearDevices">
+ <button type="button"
+ class="btn btn-light"
+ (click)="showSelectionModal()"
+ [disabled]="availDevices.length === 0 || !canSelect">
+ <i [ngClass]="[icons.add]"></i>
+ <ng-container i18n>Add</ng-container>
+ </button>
+ </ng-container>
+ <ng-template #blockClearDevices>
+ <div class="pb-2 my-2 border-bottom">
+ <span *ngFor="let filter of appliedFilters">
+ <span class="badge badge-dark mr-2">{{ filter.label }}: {{ filter.formatValue }}</span>
+ </span>
+ <a class="tc_clearSelections"
+ href=""
+ (click)="clearDevices(); false">
+ <i [ngClass]="[icons.clearFilters]"></i>
+ <ng-container i18n>Clear</ng-container>
+ </a>
+ </div>
+ <div>
+ <cd-inventory-devices [devices]="devices"
+ [hiddenColumns]="['available']"
+ [filterColumns]="[]">
+ </cd-inventory-devices>
+ </div>
+ </ng-template>
+ </div>
+</div>
--- /dev/null
+.tc_clearSelections {
+ text-decoration: none;
+}
--- /dev/null
+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<OsdDevicesSelectionGroupsComponent>;
+ 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);
+ });
+ });
+});
--- /dev/null
+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<DevicesSelectionChangeEvent>();
+
+ @Output()
+ cleared = new EventEmitter<DevicesSelectionClearEvent>();
+
+ 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);
+ }
+}
--- /dev/null
+<cd-modal [modalRef]="bsModalRef">
+ <ng-container class="modal-title"
+ i18n>Add {{ deviceType }} devices</ng-container>
+
+ <ng-container class="modal-content">
+ <form #frm="ngForm"
+ [formGroup]="formGroup"
+ novalidate>
+ <div class="modal-body">
+ <div class="col-sm-12">
+ <cd-inventory-devices [devices]="devices"
+ [filterColumns]="filterColumns"
+ [hiddenColumns]="['available', 'osd_ids']"
+ (filterChange)="onFilterChange($event)">
+ </cd-inventory-devices>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <cd-submit-button (submitAction)="onSubmit()"
+ [form]="formGroup"
+ [disabled]="!canSubmit || filterInDevices.length === 0">{{ action | titlecase }}</cd-submit-button>
+ <cd-back-button [back]="bsModalRef.hide"></cd-back-button>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
--- /dev/null
+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<OsdDevicesSelectionModalComponent>;
+ 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);
+ });
+});
--- /dev/null
+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<InventoryDeviceFiltersChangeEvent>();
+
+ 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();
+ }
+}
--- /dev/null
+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];
+ }
+ }
+}
--- /dev/null
+export interface OsdFeature {
+ desc: string;
+ key?: string;
+}
--- /dev/null
+<cd-loading-panel *ngIf="loading"
+ i18n>Loading...</cd-loading-panel>
+<cd-alert-panel type="info"
+ *ngIf="!orchestratorExist && !checkingOrchestrator"
+ i18n>Please consult the
+ <a href="{{ docsUrl }}"
+ target="_blank">documentation</a> on how to
+ configure and enable the orchestrator functionality.</cd-alert-panel>
+<div class="col-sm-10"
+ *ngIf="!loading && orchestratorExist">
+ <form name="form"
+ #formDir="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div class="card">
+ <div i18n="form title|Example: Create Pool@@formTitle"
+ class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+ <div class="card-body">
+ <fieldset>
+ <cd-osd-devices-selection-groups #dataDeviceSelectionGroups
+ name="Primary"
+ type="data"
+ [availDevices]="availDevices"
+ [canSelect]="availDevices.length !== 0"
+ (selected)="onDevicesSelected($event)"
+ (cleared)="onDevicesCleared($event)">
+ </cd-osd-devices-selection-groups>
+ </fieldset>
+
+ <!-- Shared devices -->
+ <fieldset>
+ <legend i18n>Shared devices</legend>
+
+ <!-- WAL devices button and table -->
+ <cd-osd-devices-selection-groups #walDeviceSelectionGroups
+ name="WAL"
+ type="wal"
+ [availDevices]="availDevices"
+ [canSelect]="dataDeviceSelectionGroups.devices.length !== 0"
+ (selected)="onDevicesSelected($event)"
+ (cleared)="onDevicesCleared($event)">
+ </cd-osd-devices-selection-groups>
+
+ <!-- WAL slots -->
+ <div class="form-group row"
+ *ngIf="walDeviceSelectionGroups.devices.length !== 0">
+ <label class="col-sm-3 col-form-label"
+ for="walSlots">
+ <ng-container i18n>WAL slots</ng-container>
+ <cd-helper>
+ <span i18n>How many OSDs per WAL device.</span>
+ <br>
+ <span i18n>Specify 0 to let Orchestrator backend decide it.</span>
+ </cd-helper>
+ </label>
+ <div class="col-sm-9">
+ <input class="form-control"
+ id="walSlots"
+ name="walSlots"
+ type="number"
+ min="0"
+ formControlName="walSlots">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('walSlots', formDir, 'min')"
+ i18n>Value should be greater than or equal to 0</span>
+ </div>
+ </div>
+
+ <!-- DB devices button and table -->
+ <cd-osd-devices-selection-groups #dbDeviceSelectionGroups
+ name="DB"
+ type="db"
+ [availDevices]="availDevices"
+ [canSelect]="dataDeviceSelectionGroups.devices.length !== 0"
+ (selected)="onDevicesSelected($event)"
+ (cleared)="onDevicesCleared($event)">
+ </cd-osd-devices-selection-groups>
+
+ <!-- DB slots -->
+ <div class="form-group row"
+ *ngIf="dbDeviceSelectionGroups.devices.length !== 0">
+ <label class="col-sm-3 col-form-label"
+ for="dbSlots">
+ <ng-container i18n>DB slots</ng-container>
+ <cd-helper>
+ <span i18n>How many OSDs per DB device.</span>
+ <br>
+ <span i18n>Specify 0 to let Orchestrator backend decide it.</span>
+ </cd-helper>
+ </label>
+ <div class="col-sm-9">
+ <input class="form-control"
+ id="dbSlots"
+ name="dbSlots"
+ type="number"
+ min="0"
+ formControlName="dbSlots">
+ <span class="invalid-feedback"
+ *ngIf="form.showError('dbSlots', formDir, 'min')"
+ i18n>Value should be greater than or equal to 0</span>
+ </div>
+ </div>
+ </fieldset>
+
+ <!-- Configuration -->
+ <fieldset>
+ <legend i18n>Configuration</legend>
+
+ <!-- Features -->
+ <div class="form-group row"
+ formGroupName="features">
+ <label i18n
+ class="col-sm-3 col-form-label"
+ for="features">Features</label>
+ <div class="col-sm-9">
+ <div class="custom-control custom-checkbox"
+ *ngFor="let feature of featureList">
+ <input type="checkbox"
+ class="custom-control-input"
+ id="{{ feature.key }}"
+ name="{{ feature.key }}"
+ formControlName="{{ feature.key }}">
+ <label class="custom-control-label"
+ for="{{ feature.key }}">{{ feature.desc }}</label>
+ </div>
+ </div>
+ </div>
+ </fieldset>
+ </div>
+ <div class="card-footer">
+ <div class="button-group text-right">
+ <cd-submit-button #previewButton
+ (submitAction)="submit()"
+ i18n
+ [form]="formDir"
+ [disabled]="dataDeviceSelectionGroups.devices.length === 0">Preview</cd-submit-button>
+ <cd-back-button></cd-back-button>
+ </div>
+ </div>
+ </div>
+ </form>
+</div>
--- /dev/null
+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<OsdFormComponent>;
+ 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);
+ });
+ });
+ });
+ });
+ });
+});
--- /dev/null
+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<CdTableColumn> = [];
+
+ 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;
+ }
+}
expect(tableActions).toEqual({
'create,update,delete': {
actions: [
+ 'Create',
'Scrub',
'Deep Scrub',
'Reweight',
'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',
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);
});
});
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 })
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,
@ViewChild(TableComponent, { static: false })
table: TableComponent;
- @Input() hostname = '';
+ @Input() hostname: string;
+
+ // Do not display these columns
+ @Input() hiddenColumns: string[] = [];
checkingOrchestrator = true;
orchestratorExist = false;
) {}
ngOnInit() {
- this.columns = [
+ const columns = [
+ {
+ name: this.i18n('Hostname'),
+ prop: 'nodename',
+ flexGrow: 2
+ },
{
name: this.i18n('Service type'),
prop: 'service_type',
}
];
- 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) => {
+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);
+ });
});
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({
statusURL = 'api/orchestrator/status';
inventoryURL = 'api/orchestrator/inventory';
serviceURL = 'api/orchestrator/service';
+ osdURL = 'api/orchestrator/osd';
constructor(private http: HttpClient) {}
return this.http.get(this.statusURL);
}
- inventoryList(hostname: string) {
+ inventoryList(hostname?: string): Observable<InventoryNode[]> {
const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {};
- return this.http.get(this.inventoryURL, options);
+ return this.http.get<InventoryNode[]>(this.inventoryURL, options);
+ }
+
+ inventoryDeviceList(hostname?: string): Observable<InventoryDevice[]> {
+ 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' });
+ }
}
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
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 {