]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: support creating OSDs on spare devices
authorKiefer Chang <kiefer.chang@suse.com>
Tue, 15 Oct 2019 03:03:13 +0000 (11:03 +0800)
committerKiefer Chang <kiefer.chang@suse.com>
Tue, 26 Nov 2019 04:29:01 +0000 (12:29 +0800)
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 <kiefer.chang@suse.com>
46 files changed:
src/pybind/mgr/dashboard/frontend/e2e/cluster/osds.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-applied-filters.interface.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-filter.interface.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device-filters-change-event.interface.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-device.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-node.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-change-event.interface.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/devices-selection-clear-event.interface.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-modal/osd-devices-selection-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/drive-group.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-feature.interface.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts

index 8671d5a5091040467bf2489836b48e12cd4c83e0..f4a08281ae7663434cfbd46772af72f711ca9b6a 100644 (file)
@@ -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);
index 875ca13f5fbc3fbebe95f237b4a096fcbe167d5c..a483ba78bd615f77a1d3a6a20c8616734db406ed 100644 (file)
@@ -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',
index 57cd06841e2946996b88d1f3101602f8e9293e43..25b22a7157c07ce635c6c5688129d663bcae98c5 100644 (file)
@@ -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 {}
index 41e15a46996f04c086ae8b94c035472cc9446740..c38b669c82b74d9f35ba4df5a0ce97f21d5817c2 100644 (file)
@@ -12,7 +12,9 @@
   <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
index 834ccea6aaf3c6d14804de9caeb488e33f7bd6c2..354b67bc3316623906bc010d52e7eb62cf067339 100644 (file)
@@ -1,11 +1,11 @@
 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';
@@ -23,6 +23,7 @@ describe('HostDetailsComponent', () => {
       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();
   });
index 2d8e16c4748cde2c43e0f69473e5b014fe79f400..9ad1fde69443257ce14277eecbfbe2db9744b392 100644 (file)
@@ -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 (file)
index 0000000..a1dc128
--- /dev/null
@@ -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 (file)
index 0000000..0f46bff
--- /dev/null
@@ -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 (file)
index 0000000..b85f6f0
--- /dev/null
@@ -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 (file)
index 0000000..4af9137
--- /dev/null
@@ -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 (file)
index 0000000..f9a2854
--- /dev/null
@@ -0,0 +1,43 @@
+<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">&nbsp;</span>
+  </span>
+</ng-template>
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 (file)
index 0000000..e2eb035
--- /dev/null
@@ -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 (file)
index 0000000..03a0026
--- /dev/null
@@ -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<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);
+    });
+  });
+});
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 (file)
index 0000000..1b8a00b
--- /dev/null
@@ -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<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
+    });
+  }
+}
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 (file)
index 0000000..41c38b6
--- /dev/null
@@ -0,0 +1,6 @@
+import { InventoryDevice } from './inventory-devices/inventory-device.model';
+
+export class InventoryNode {
+  name: string;
+  devices: InventoryDevice[];
+}
index 01c4175efe7ea3b356483955fa1c647e1756d24a..8f573cf9810eff679f7b1efb2591426a856506a3 100644 (file)
@@ -9,22 +9,10 @@
   <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">&nbsp;</span>
-  </span>
-</ng-template>
index 9e3fb85fc0b24e9c3fb72cc837e9ed637967ee0c..f4455fbb8abfe73b872365eba81731ee3ab1f504 100644 (file)
@@ -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<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');
+    });
   });
 });
index 9bbcac8328c9acbe4b236e78e3e176b19e12a10b..f7481ec417df850aee5fbf1390bd7b67ef4ee8a8 100644 (file)
@@ -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<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) {
@@ -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 (file)
index b7cd2ea..0000000
+++ /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 (file)
index 0000000..fd0acad
--- /dev/null
@@ -0,0 +1,20 @@
+<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>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..36fe3b0
--- /dev/null
@@ -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<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();
+  });
+});
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 (file)
index 0000000..82b14ae
--- /dev/null
@@ -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 (file)
index 0000000..0bac44e
--- /dev/null
@@ -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 (file)
index 0000000..58b7c82
--- /dev/null
@@ -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 (file)
index 0000000..fc1df4e
--- /dev/null
@@ -0,0 +1,45 @@
+<!-- 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>
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 (file)
index 0000000..3fb8f6b
--- /dev/null
@@ -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 (file)
index 0000000..f15f3a0
--- /dev/null
@@ -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<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);
+    });
+  });
+});
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 (file)
index 0000000..0fa59d2
--- /dev/null
@@ -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<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);
+  }
+}
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 (file)
index 0000000..a543a95
--- /dev/null
@@ -0,0 +1,26 @@
+<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>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..d58da46
--- /dev/null
@@ -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<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);
+  });
+});
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 (file)
index 0000000..080cdec
--- /dev/null
@@ -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<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();
+  }
+}
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 (file)
index 0000000..8201eed
--- /dev/null
@@ -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 (file)
index 0000000..8c9dc45
--- /dev/null
@@ -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 (file)
index 0000000..4b0a0c1
--- /dev/null
@@ -0,0 +1,142 @@
+<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>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..ee2648a
--- /dev/null
@@ -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<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);
+          });
+        });
+      });
+    });
+  });
+});
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 (file)
index 0000000..0bc0179
--- /dev/null
@@ -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<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;
+  }
+}
index b42dbf54a1ba0977a96327e26ae634db414d4866..ac6ce511b90bfd2f01c72abab1aeb097776e9d41 100644 (file)
@@ -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);
       });
     });
index d8d0e2fd78a7d18db26ea52e7ffec2eea9050e5b..42f6775634d41f4cd8a05fbdcbd11d4acee76c7e 100644 (file)
@@ -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,
index 692b28b5839b1cb1010904f4d8ad785d4fab3f32..5ee096fd4cf07c7816be2cab20252c1fb2ee7657 100644 (file)
@@ -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) => {
index 993202244835842b37fa1b55d55d3c314497b648..06d87a17706aa5a2058899f2d85f6c4b19e30424 100644 (file)
@@ -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);
+  });
 });
index 7123e7258e83c46feda268c8a62dec593168e97b..b3bbfc5db257aca72f6150bccde1978dc8f23f99 100644 (file)
@@ -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<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' });
+  }
 }
index 874d7dc717670aa68b832431f4828cb90aaf0bcb..8e2d0c67eede2daac395fa2135a0ecd5bb25044b 100644 (file)
@@ -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
index 20605b028f9de1f6211dea09b16d0d94b189bd70..facea81c7215f517fbf508f74ded4cb2a4e94ee8 100644 (file)
@@ -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 {