]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: OSD Creation Workflow initial works
authorNizamudeen A <nia@redhat.com>
Tue, 22 Feb 2022 10:21:03 +0000 (15:51 +0530)
committerPere Diaz Bou <pdiazbou@redhat.com>
Wed, 15 Jun 2022 09:10:05 +0000 (11:10 +0200)
Introducing the Cost/Capacity Optimized deployment option
Used bootstrap accordion
Adapted the e2e but not written new tests for the deployment option

Fixes: https://tracker.ceph.com/issues/54340
Fixes: https://tracker.ceph.com/issues/54563
Signed-off-by: Nizamudeen A <nia@redhat.com>
Signed-off-by: Sarthak0702 <sarthak.0702@gmail.com>
(cherry picked from commit 6c2dcb740efb793a3f6ef593793151a34c19ca01)

17 files changed:
src/pybind/mgr/dashboard/controllers/osd.py
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/osds.po.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-creation-preview-modal/osd-creation-preview-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_basics.scss
src/pybind/mgr/dashboard/services/osd.py [new file with mode: 0644]
src/pybind/mgr/dashboard/tests/test_osd.py

index d52b652ee210503abcb10544f415e2d2f648e3c3..4824e50ca76f0ab1b8fdfa2d914e80c49696ad6d 100644 (file)
@@ -15,6 +15,7 @@ from ..security import Scope
 from ..services.ceph_service import CephService, SendCommandError
 from ..services.exception import handle_orchestrator_error, handle_send_command_error
 from ..services.orchestrator import OrchClient, OrchFeature
+from ..services.osd import HostStorageSummary, OsdDeploymentOptions
 from ..tools import str_to_bool
 from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \
     EndpointDoc, ReadPermission, RESTController, Task, UIRouter, \
@@ -48,35 +49,22 @@ EXPORT_INDIV_FLAGS_GET_SCHEMA = {
 }
 
 
-class DeploymentOption:
-    def __init__(self, name: str, available=False, capacity=0, used=0, hdd_used=0,
-                 ssd_used=0, nvme_used=0):
-        self.name = name
-        self.available = available
-        self.capacity = capacity
-        self.used = used
-        self.hdd_used = hdd_used
-        self.ssd_used = ssd_used
-        self.nvme_used = nvme_used
-
-    def as_dict(self):
-        return {
-            'name': self.name,
-            'available': self.available,
-            'capacity': self.capacity,
-            'used': self.used,
-            'hdd_used': self.hdd_used,
-            'ssd_used': self.ssd_used,
-            'nvme_used': self.nvme_used
-        }
-
-
 class DeploymentOptions:
     def __init__(self):
         self.options = {
-            'cost-capacity': DeploymentOption('cost-capacity'),
-            'throughput': DeploymentOption('throughput-optimized'),
-            'iops': DeploymentOption('iops-optimized'),
+            OsdDeploymentOptions.COST_CAPACITY:
+                HostStorageSummary(OsdDeploymentOptions.COST_CAPACITY,
+                                   title='Cost/Capacity-optimized',
+                                   desc='All the available HDDs are selected'),
+            OsdDeploymentOptions.THROUGHPUT:
+                HostStorageSummary(OsdDeploymentOptions.THROUGHPUT,
+                                   title='Throughput-optimized',
+                                   desc="HDDs/SSDs are selected for data"
+                                   "devices and SSDs/NVMes for DB/WAL devices"),
+            OsdDeploymentOptions.IOPS:
+                HostStorageSummary(OsdDeploymentOptions.IOPS,
+                                   title='IOPS-optimized',
+                                   desc='All the available NVMes are selected'),
         }
         self.recommended_option = None
 
@@ -88,17 +76,19 @@ class DeploymentOptions:
 
 
 predefined_drive_groups = {
-    'cost-capacity': {
+    OsdDeploymentOptions.COST_CAPACITY: {
         'service_type': 'osd',
+        'service_id': 'cost_capacity',
         'placement': {
             'host_pattern': '*'
         },
         'data_devices': {
             'rotational': 1
-        }
+        },
+        'encrypted': False
     },
-    'throughput': {},
-    'iops': {},
+    OsdDeploymentOptions.THROUGHPUT: {},
+    OsdDeploymentOptions.IOPS: {},
 }
 
 
@@ -348,10 +338,12 @@ class Osd(RESTController):
 
     def _create_predefined_drive_group(self, data):
         orch = OrchClient.instance()
-        if data == 'cost-capacity':
+        if OsdDeploymentOptions(data[0]['option']) == OsdDeploymentOptions.COST_CAPACITY:
             try:
+                predefined_drive_groups[
+                    OsdDeploymentOptions.COST_CAPACITY]['encrypted'] = data[0]['encrypted']
                 orch.osds.create([DriveGroupSpec.from_json(
-                    predefined_drive_groups['cost-capacity'])])
+                    predefined_drive_groups[OsdDeploymentOptions.COST_CAPACITY])])
             except (ValueError, TypeError, DriveGroupValidationError) as e:
                 raise DashboardException(e, component='osd')
 
@@ -474,7 +466,7 @@ class Osd(RESTController):
 @UIRouter('/osd', Scope.OSD)
 @APIDoc("Dashboard UI helper function; not part of the public API", "OsdUI")
 class OsdUi(Osd):
-    @Endpoint('GET', version=APIVersion.EXPERIMENTAL)
+    @Endpoint('GET')
     @ReadPermission
     @raise_if_no_orchestrator([OrchFeature.DAEMON_LIST])
     @handle_orchestrator_error('host')
@@ -485,6 +477,7 @@ class OsdUi(Osd):
         nvmes = 0
         res = DeploymentOptions()
         devices = {}
+
         for inventory_host in orch.inventory.list(hosts=None, refresh=True):
             for device in inventory_host.devices.devices:
                 if device.available:
@@ -496,8 +489,8 @@ class OsdUi(Osd):
                     elif device.human_readable_type == 'nvme':
                         nvmes += 1
         if hdds:
-            res.options['cost-capacity'].available = True
-            res.recommended_option = 'cost-capacity'
+            res.options[OsdDeploymentOptions.COST_CAPACITY].available = True
+            res.recommended_option = OsdDeploymentOptions.COST_CAPACITY
         return res.as_dict()
 
 
index d388a3c5ba6e6439306aca9c5f4a1c454aef3cf7..cd812f474fb89ae9d55a3275d5181954e79f7725 100644 (file)
@@ -14,6 +14,7 @@ export class OSDsPageHelper extends PageHelper {
   };
 
   create(deviceType: 'hdd' | 'ssd', hostname?: string, expandCluster = false) {
+    cy.get('[aria-label="toggle advanced mode"]').click();
     // Click Primary devices Add button
     cy.get('cd-osd-devices-selection-groups[name="Primary"]').as('primaryGroups');
     cy.get('@primaryGroups').find('button').click();
index 4a4bb109472f83303572e7da1b7f97375d6599ef..4808773727b1c93a7190a984950b315a7c7a1504 100644 (file)
              class="ml-5">
           <h4 class="title"
               i18n>Create OSDs</h4>
-          <br>
           <div class="alignForm">
             <cd-osd-form [hideTitle]="true"
                          [hideSubmitBtn]="true"
-                         (emitDriveGroup)="getDriveGroup($event)"></cd-osd-form>
+                         (emitDriveGroup)="setDriveGroup($event)"
+                         (emitDeploymentOption)="setDeploymentOptions($event)"
+                         (emitMode)="setDeploymentMode($event)"></cd-osd-form>
           </div>
         </div>
         <div *ngSwitchCase="'3'"
index a2e88899a66e261ca6682178fd0c3be7b0c8f56f..313f3193bfbabe24f1b02b38c75fbf866f70dc45 100644 (file)
@@ -1,5 +1,3 @@
-@use './src/styles/vendor/variables' as vv;
-
 .container-fluid {
   align-items: flex-start;
   display: flex;
@@ -7,24 +5,18 @@
   width: 100%;
 }
 
-.card-body {
-  max-width: 85%;
-}
-
-.vertical-line {
-  border-left: 1px solid vv.$gray-400;
-}
-
-cd-wizard {
-  width: 15%;
-}
-
 cd-hosts {
   ::ng-deep .nav {
     display: none;
   }
 }
 
-.alignForm {
-  margin-left: -1%;
+cd-osd-form {
+  ::ng-deep .card {
+    border: 0;
+  }
+
+  ::ng-deep .accordion {
+    margin-left: -1.5rem;
+  }
 }
index 3e0b7f7bdcce71ac83f165127ab8044909110501..0563c4a803a951952c741a86786a58369e39b88d 100644 (file)
@@ -131,6 +131,7 @@ describe('CreateClusterComponent', () => {
   });
 
   it('should ensure osd creation did not happen when no devices are selected', () => {
+    component.simpleDeployment = false;
     const osdServiceSpy = spyOn(osdService, 'create').and.callThrough();
     component.onSubmit();
     fixture.detectChanges();
index 743902b712d8c36bfd5c9537973054b6a8b1c087..02333c39bf6170a958580108aa88abac9606cda1 100644 (file)
@@ -1,4 +1,12 @@
-import { Component, EventEmitter, OnDestroy, Output, TemplateRef, ViewChild } from '@angular/core';
+import {
+  Component,
+  EventEmitter,
+  OnDestroy,
+  OnInit,
+  Output,
+  TemplateRef,
+  ViewChild
+} from '@angular/core';
 import { Router } from '@angular/router';
 
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
@@ -13,6 +21,7 @@ import { ConfirmationModalComponent } from '~/app/shared/components/confirmation
 import { ActionLabelsI18n, AppConstants, URLVerbs } from '~/app/shared/constants/app.constants';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { FinishedTask } from '~/app/shared/models/finished-task';
+import { DeploymentOptions } from '~/app/shared/models/osd-deployment-options';
 import { Permissions } from '~/app/shared/models/permissions';
 import { WizardStepModel } from '~/app/shared/models/wizard-steps';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
@@ -27,7 +36,7 @@ import { DriveGroup } from '../osd/osd-form/drive-group.model';
   templateUrl: './create-cluster.component.html',
   styleUrls: ['./create-cluster.component.scss']
 })
-export class CreateClusterComponent implements OnDestroy {
+export class CreateClusterComponent implements OnInit, OnDestroy {
   @ViewChild('skipConfirmTpl', { static: true })
   skipConfirmTpl: TemplateRef<any>;
   currentStep: WizardStepModel;
@@ -40,6 +49,9 @@ export class CreateClusterComponent implements OnDestroy {
   modalRef: NgbModalRef;
   driveGroup = new DriveGroup();
   driveGroups: Object[] = [];
+  deploymentOption: DeploymentOptions;
+  selectedOption = {};
+  simpleDeployment = true;
 
   @Output()
   submitAction = new EventEmitter();
@@ -65,6 +77,13 @@ export class CreateClusterComponent implements OnDestroy {
     this.currentStep.stepIndex = 1;
   }
 
+  ngOnInit(): void {
+    this.osdService.getDeploymentOptions().subscribe((options) => {
+      this.deploymentOption = options;
+      this.selectedOption = { option: options.recommended_option };
+    });
+  }
+
   createCluster() {
     this.startClusterCreation = true;
   }
@@ -118,34 +137,63 @@ export class CreateClusterComponent implements OnDestroy {
           error: (error) => error.preventDefault()
         });
     });
+
     if (this.driveGroup) {
       const user = this.authStorageService.getUsername();
       this.driveGroup.setName(`dashboard-${user}-${_.now()}`);
       this.driveGroups.push(this.driveGroup.spec);
     }
 
-    if (this.osdService.osdDevices['totalDevices'] > 0) {
+    if (this.simpleDeployment) {
+      const title = this.deploymentOption?.options[this.selectedOption['option']].title;
+      const trackingId = $localize`${title} deployment`;
       this.taskWrapper
         .wrapTaskAroundCall({
           task: new FinishedTask('osd/' + URLVerbs.CREATE, {
-            tracking_id: _.join(_.map(this.driveGroups, 'service_id'), ', ')
+            tracking_id: trackingId
           }),
-          call: this.osdService.create(this.driveGroups)
+          call: this.osdService.create([this.selectedOption], trackingId, 'predefined')
         })
         .subscribe({
           error: (error) => error.preventDefault(),
           complete: () => {
             this.submitAction.emit();
-            this.osdService.osdDevices = [];
           }
         });
+    } else {
+      if (this.osdService.osdDevices['totalDevices'] > 0) {
+        this.driveGroup.setFeature('encrypted', this.selectedOption['encrypted']);
+        const trackingId = _.join(_.map(this.driveGroups, 'service_id'), ', ');
+        this.taskWrapper
+          .wrapTaskAroundCall({
+            task: new FinishedTask('osd/' + URLVerbs.CREATE, {
+              tracking_id: trackingId
+            }),
+            call: this.osdService.create(this.driveGroups, trackingId)
+          })
+          .subscribe({
+            error: (error) => error.preventDefault(),
+            complete: () => {
+              this.submitAction.emit();
+              this.osdService.osdDevices = [];
+            }
+          });
+      }
     }
   }
 
-  getDriveGroup(driveGroup: DriveGroup) {
+  setDriveGroup(driveGroup: DriveGroup) {
     this.driveGroup = driveGroup;
   }
 
+  setDeploymentOptions(option: object) {
+    this.selectedOption = option;
+  }
+
+  setDeploymentMode(mode: boolean) {
+    this.simpleDeployment = mode;
+  }
+
   onNextStep() {
     if (!this.wizardStepsService.isLastStep()) {
       this.wizardStepsService.getCurrentStep().subscribe((step: WizardStepModel) => {
index 979dcc3411fb55ed59b1e2f75107fb4cd3e167ee..3e1b0f067c47aae60ceeecb207bac3fe670de3fe 100644 (file)
@@ -41,12 +41,13 @@ export class OsdCreationPreviewModalComponent {
   }
 
   onSubmit() {
+    const trackingId = _.join(_.map(this.driveGroups, 'service_id'), ', ');
     this.taskWrapper
       .wrapTaskAroundCall({
         task: new FinishedTask('osd/' + URLVerbs.CREATE, {
-          tracking_id: _.join(_.map(this.driveGroups, 'service_id'), ', ')
+          tracking_id: trackingId
         }),
-        call: this.osdService.create(this.driveGroups)
+        call: this.osdService.create(this.driveGroups, trackingId)
       })
       .subscribe({
         error: () => {
index 59a17362f6f834eb55da7c9f3999e8e90202dbbc..d4b6d9faea1099d2ce677d218a838d4d35035081 100644 (file)
 <cd-orchestrator-doc-panel *ngIf="!hasOrchestrator"></cd-orchestrator-doc-panel>
 
-<div class="cd-col-form"
+<div class="card"
      *cdFormLoading="loading">
-  <form name="form"
-        #formDir="ngForm"
-        [formGroup]="form"
-        novalidate>
-    <div class="card">
-      <div i18n="form title|Example: Create Pool@@formTitle"
-           class="card-header"
-           *ngIf="!hideTitle">{{ 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>
+  <div i18n="form title|Example: Create Pool@@formTitle"
+       class="card-header"
+       *ngIf="!hideTitle">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+  <div class="card-body ml-2">
+    <form name="form"
+          #formDir="ngForm"
+          [formGroup]="form"
+          novalidate>
+      <div class="accordion">
+        <div class="card">
+          <div class="card-header">
+            <h2 class="mb-0">
+              <button class="btn btn-link btn-block text-left dropdown-toggle"
+                      data-toggle="collapse"
+                      aria-label="toggle deployment options"
+                      [attr.aria-expanded]="simpleDeployment"
+                      (click)="emitDeploymentMode()"
+                      i18n>Deployment Options</button>
+            </h2>
+          </div>
+        </div>
+        <div class="collapse"
+             [ngClass]="{show: simpleDeployment}">
+          <div class="card-body d-flex flex-column">
+            <div class="pt-3 pb-3"
+                 *ngFor="let optionName of optionNames">
+              <div class="custom-control custom-radio custom-control-inline">
+                <input class="custom-control-input"
+                       type="radio"
+                       name="deploymentOption"
+                       [id]="optionName"
+                       [value]="optionName"
+                       formControlName="deploymentOption"
+                       (change)="emitDeploymentSelection()"
+                       [attr.disabled]="!deploymentOptions?.options[optionName].available ? true : null">
+                <label class="custom-control-label"
+                       [id]="'label_' + optionName"
+                       [for]="optionName"
+                       i18n>{{ deploymentOptions?.options[optionName].title }}
+                       {{ deploymentOptions.recommended_option === optionName ? "(Recommended)" : "" }}
+                  <cd-helper>
+                    <span>{{ deploymentOptions?.options[optionName].desc }}</span>
+                  </cd-helper>
+                </label>
+              </div>
+            </div>
+            <!-- @TODO: Visualize the storage used on a chart -->
+            <!-- <div class="pie-chart">
+              <h4 class="text-center">Selected Capacity</h4>
+              <h5 class="margin text-center">10 Hosts | 30 NVMes </h5>
+              <div class="char-i-contain">
+                <cd-health-pie [data]="data"
+                               [config]="rawCapacityChartConfig"
+                               [isBytesData]="true"
+                               (prepareFn)="prepareRawUsage($event[0], $event[1])">
+                </cd-health-pie>
+              </div>
+            </div> -->
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-header">
+            <h2 class="mb-0">
+              <button class="btn btn-link btn-block text-left dropdown-toggle"
+                      data-toggle="collapse"
+                      aria-label="toggle advanced mode"
+                      [attr.aria-expanded]="!simpleDeployment"
+                      (click)="emitDeploymentMode()"
+                      i18n>Advanced Mode</button>
+            </h2>
+          </div>
+        </div>
+        <div class="collapse"
+             [ngClass]="{show: !simpleDeployment}">
+          <div class="card-body">
+            <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>
+              <!-- 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 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="cd-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="cd-col-form-input">
-              <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>
+                <!-- WAL slots -->
+                <div class="form-group row"
+                     *ngIf="walDeviceSelectionGroups.devices.length !== 0">
+                  <label class="cd-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="cd-col-form-input">
+                    <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 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="cd-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="cd-col-form-input">
-              <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>
+                <!-- DB slots -->
+                <div class="form-group row"
+                     *ngIf="dbDeviceSelectionGroups.devices.length !== 0">
+                  <label class="cd-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="cd-col-form-input">
+                    <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>
             </div>
           </div>
-        </fieldset>
-
-        <!-- Configuration -->
-        <fieldset>
-          <legend i18n>Configuration</legend>
+        </div>
 
-          <!-- Features -->
-          <div class="form-group row"
-               formGroupName="features">
-            <label i18n
-                   class="cd-col-form-label"
-                   for="features">Features</label>
-            <div class="cd-col-form-input">
+        <!-- Features -->
+        <div class="card">
+          <div class="card-header">
+            <h2 class="mb-0">
+              <button class="btn btn-link btn-block text-left dropdown-toggle"
+                      data-toggle="collapse"
+                      aria-label="features"
+                      aria-expanded="true"
+                      i18n>Features</button>
+            </h2>
+          </div>
+        </div>
+        <div class="collapse show">
+          <div class="card-body d-flex flex-column">
+            <div class="pt-3 pb-3"
+                 formGroupName="features">
               <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 }}">
+                       formControlName="{{ feature.key }}"
+                       (change)="emitDeploymentSelection()">
                 <label class="custom-control-label"
                        for="{{ feature.key }}">{{ feature.desc }}</label>
               </div>
             </div>
           </div>
-        </fieldset>
-      </div>
-      <div class="card-footer"
-           *ngIf="!hideSubmitBtn">
-        <cd-form-button-panel #previewButtonPanel
-                              (submitActionEvent)="submit()"
-                              [form]="form"
-                              [disabled]="dataDeviceSelectionGroups.devices.length === 0"
-                              [submitText]="actionLabels.PREVIEW"
-                              wrappingClass="text-right"></cd-form-button-panel>
+        </div>
       </div>
-    </div>
-  </form>
+    </form>
+  </div>
+
+  <div class="card-footer"
+       *ngIf="!hideSubmitBtn">
+    <cd-form-button-panel #previewButtonPanel
+                          (submitActionEvent)="submit()"
+                          [form]="form"
+                          [disabled]="dataDeviceSelectionGroups.devices.length === 0 && !simpleDeployment"
+                          [submitText]="simpleDeployment ? 'Create OSDs' : actionLabels.PREVIEW"
+                          wrappingClass="text-right"></cd-form-button-panel>
+  </div>
 </div>
index 2044b084c7aac81ea02ec1a82828278c74a2c3f7..725fc953fbb6c6d1cbe25f048cd37b9cdb2242a1 100644 (file)
@@ -9,9 +9,14 @@ import { BehaviorSubject, of } from 'rxjs';
 
 import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
 import { InventoryDevicesComponent } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component';
+import { DashboardModule } from '~/app/ceph/dashboard/dashboard.module';
 import { HostService } from '~/app/shared/api/host.service';
 import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import {
+  DeploymentOptions,
+  OsdDeploymentOptions
+} from '~/app/shared/models/osd-deployment-options';
 import { SummaryService } from '~/app/shared/services/summary.service';
 import { SharedModule } from '~/app/shared/shared.module';
 import { configureTestBed, FixtureHelper, FormHelper } from '~/testing/unit-test-helper';
@@ -50,6 +55,45 @@ describe('OsdFormComponent', () => {
     }
   ];
 
+  const deploymentOptions: DeploymentOptions = {
+    options: {
+      cost_capacity: {
+        name: OsdDeploymentOptions.COST_CAPACITY,
+        available: true,
+        capacity: 0,
+        used: 0,
+        hdd_used: 0,
+        ssd_used: 0,
+        nvme_used: 0,
+        title: 'Cost/Capacity-optimized',
+        desc: 'All the available HDDs are selected'
+      },
+      throughput_optimized: {
+        name: OsdDeploymentOptions.THROUGHPUT,
+        available: false,
+        capacity: 0,
+        used: 0,
+        hdd_used: 0,
+        ssd_used: 0,
+        nvme_used: 0,
+        title: 'Throughput-optimized',
+        desc: 'HDDs/SSDs are selected for data devices and SSDs/NVMes for DB/WAL devices'
+      },
+      iops_optimized: {
+        name: OsdDeploymentOptions.IOPS,
+        available: false,
+        capacity: 0,
+        used: 0,
+        hdd_used: 0,
+        ssd_used: 0,
+        nvme_used: 0,
+        title: 'IOPS-optimized',
+        desc: 'All the available NVMes are selected'
+      }
+    },
+    recommended_option: OsdDeploymentOptions.COST_CAPACITY
+  };
+
   const expectPreviewButton = (enabled: boolean) => {
     const debugElement = fixtureHelper.getElementByCss('.tc_submitButton');
     expect(debugElement.nativeElement.disabled).toBe(!enabled);
@@ -99,7 +143,8 @@ describe('OsdFormComponent', () => {
       SharedModule,
       RouterTestingModule,
       ReactiveFormsModule,
-      ToastrModule.forRoot()
+      ToastrModule.forRoot(),
+      DashboardModule
     ],
     declarations: [OsdFormComponent, OsdDevicesSelectionGroupsComponent, InventoryDevicesComponent]
   });
@@ -141,14 +186,53 @@ describe('OsdFormComponent', () => {
 
   describe('with orchestrator', () => {
     beforeEach(() => {
+      component.simpleDeployment = false;
       spyOn(orchService, 'status').and.returnValue(of({ available: true }));
       spyOn(hostService, 'inventoryDeviceList').and.returnValue(of([]));
+      component.deploymentOptions = deploymentOptions;
+      fixture.detectChanges();
+    });
+
+    it('should display the accordion', () => {
+      fixtureHelper.expectElementVisible('.card-body .accordion', true);
+    });
+
+    it('should display the three deployment scenarios', () => {
+      fixtureHelper.expectElementVisible('#cost_capacity', true);
+      fixtureHelper.expectElementVisible('#throughput_optimized', true);
+      fixtureHelper.expectElementVisible('#iops_optimized', true);
+    });
+
+    it('should only disable the options that are not available', () => {
+      let radioBtn = fixtureHelper.getElementByCss('#throughput_optimized').nativeElement;
+      expect(radioBtn.disabled).toBeTruthy();
+      radioBtn = fixtureHelper.getElementByCss('#iops_optimized').nativeElement;
+      expect(radioBtn.disabled).toBeTruthy();
+
+      // Make the throughput_optimized option available and verify the option is not disabled
+      deploymentOptions.options['throughput_optimized'].available = true;
+      fixture.detectChanges();
+      radioBtn = fixtureHelper.getElementByCss('#throughput_optimized').nativeElement;
+      expect(radioBtn.disabled).toBeFalsy();
+    });
+
+    it('should be a Recommended option only when it is recommended by backend', () => {
+      const label = fixtureHelper.getElementByCss('#label_cost_capacity').nativeElement;
+      const throughputLabel = fixtureHelper.getElementByCss('#label_throughput_optimized')
+        .nativeElement;
+
+      expect(label.innerHTML).toContain('Recommended');
+      expect(throughputLabel.innerHTML).not.toContain('Recommended');
+
+      deploymentOptions.recommended_option = OsdDeploymentOptions.THROUGHPUT;
       fixture.detectChanges();
+      expect(throughputLabel.innerHTML).toContain('Recommended');
+      expect(label.innerHTML).not.toContain('Recommended');
     });
 
     it('should display form', () => {
       fixtureHelper.expectElementVisible('cd-alert-panel', false);
-      fixtureHelper.expectElementVisible('.cd-col-form form', true);
+      fixtureHelper.expectElementVisible('.card-body form', true);
     });
 
     describe('without data devices selected', () => {
index 71ca2d8f7b2ea038b62051d5715b38d654c60132..c2384425e7019127d087b54ba8813dfdc50513db 100644 (file)
@@ -7,15 +7,21 @@ import _ from 'lodash';
 import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
 import { HostService } from '~/app/shared/api/host.service';
 import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { OsdService } from '~/app/shared/api/osd.service';
 import { FormButtonPanelComponent } from '~/app/shared/components/form-button-panel/form-button-panel.component';
-import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { Icons } from '~/app/shared/enum/icons.enum';
 import { CdForm } from '~/app/shared/forms/cd-form';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import {
+  DeploymentOptions,
+  OsdDeploymentOptions
+} from '~/app/shared/models/osd-deployment-options';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { ModalService } from '~/app/shared/services/modal.service';
-import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 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';
@@ -49,6 +55,10 @@ export class OsdFormComponent extends CdForm implements OnInit {
 
   @Output() emitDriveGroup: EventEmitter<DriveGroup> = new EventEmitter();
 
+  @Output() emitDeploymentOption: EventEmitter<object> = new EventEmitter();
+
+  @Output() emitMode: EventEmitter<boolean> = new EventEmitter();
+
   icons = Icons;
 
   form: CdFormGroup;
@@ -71,6 +81,11 @@ export class OsdFormComponent extends CdForm implements OnInit {
 
   hasOrchestrator = true;
 
+  simpleDeployment = true;
+
+  deploymentOptions: DeploymentOptions;
+  optionNames = Object.values(OsdDeploymentOptions);
+
   constructor(
     public actionLabels: ActionLabelsI18n,
     private authStorageService: AuthStorageService,
@@ -78,7 +93,8 @@ export class OsdFormComponent extends CdForm implements OnInit {
     private hostService: HostService,
     private router: Router,
     private modalService: ModalService,
-    public wizardStepService: WizardStepsService
+    private osdService: OsdService,
+    private taskWrapper: TaskWrapperService
   ) {
     super();
     this.resource = $localize`OSDs`;
@@ -103,6 +119,14 @@ export class OsdFormComponent extends CdForm implements OnInit {
       }
     });
 
+    this.osdService.getDeploymentOptions().subscribe((options) => {
+      this.deploymentOptions = options;
+      this.form.get('deploymentOption').setValue(this.deploymentOptions?.recommended_option);
+
+      if (this.deploymentOptions?.recommended_option) {
+        this.enableFeatures();
+      }
+    });
     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) => {
@@ -123,7 +147,8 @@ export class OsdFormComponent extends CdForm implements OnInit {
           acc[e.key] = new FormControl({ value: false, disabled: true });
           return acc;
         }, {})
-      )
+      ),
+      deploymentOption: new FormControl(0)
     });
   }
 
@@ -209,16 +234,52 @@ export class OsdFormComponent extends CdForm implements OnInit {
     }
   }
 
+  emitDeploymentSelection() {
+    const option = this.form.get('deploymentOption').value;
+    const encrypted = this.form.get('encrypted').value;
+    this.emitDeploymentOption.emit({ option: option, encrypted: encrypted });
+  }
+
+  emitDeploymentMode() {
+    this.simpleDeployment = !this.simpleDeployment;
+    if (!this.simpleDeployment && this.dataDeviceSelectionGroups.devices.length === 0) {
+      this.disableFeatures();
+    } else {
+      this.enableFeatures();
+    }
+    this.emitMode.emit(this.simpleDeployment);
+  }
+
   submit() {
-    // use user name and timestamp for drive group name
-    const user = this.authStorageService.getUsername();
-    this.driveGroup.setName(`dashboard-${user}-${_.now()}`);
-    const modalRef = this.modalService.show(OsdCreationPreviewModalComponent, {
-      driveGroups: [this.driveGroup.spec]
-    });
-    modalRef.componentInstance.submitAction.subscribe(() => {
-      this.router.navigate(['/osd']);
-    });
-    this.previewButtonPanel.submitButton.loading = false;
+    if (this.simpleDeployment) {
+      const option = this.form.get('deploymentOption').value;
+      const encrypted = this.form.get('encrypted').value;
+      const deploymentSpec = { option: option, encrypted: encrypted };
+      const title = this.deploymentOptions.options[deploymentSpec.option].title;
+      const trackingId = `${title} deployment`;
+      this.taskWrapper
+        .wrapTaskAroundCall({
+          task: new FinishedTask('osd/' + URLVerbs.CREATE, {
+            tracking_id: trackingId
+          }),
+          call: this.osdService.create([deploymentSpec], trackingId, 'predefined')
+        })
+        .subscribe({
+          complete: () => {
+            this.router.navigate(['/osd']);
+          }
+        });
+    } else {
+      // use user name and timestamp for drive group name
+      const user = this.authStorageService.getUsername();
+      this.driveGroup.setName(`dashboard-${user}-${_.now()}`);
+      const modalRef = this.modalService.show(OsdCreationPreviewModalComponent, {
+        driveGroups: [this.driveGroup.spec]
+      });
+      modalRef.componentInstance.submitAction.subscribe(() => {
+        this.router.navigate(['/osd']);
+      });
+      this.previewButtonPanel.submitButton.loading = false;
+    }
   }
 }
index 135bbaf39bc3b643b49113939ba4c9c021ada119..d1f9997791ae0605fed6d746ab6bb38c79ac00a0 100644 (file)
@@ -27,6 +27,7 @@ describe('OsdService', () => {
   });
 
   it('should call create', () => {
+    const trackingId = 'all_hdd, host1_ssd';
     const post_data = {
       method: 'drive_groups',
       data: [
@@ -47,9 +48,9 @@ describe('OsdService', () => {
           }
         }
       ],
-      tracking_id: 'all_hdd, host1_ssd'
+      tracking_id: trackingId
     };
-    service.create(post_data.data).subscribe();
+    service.create(post_data.data, trackingId).subscribe();
     const req = httpTesting.expectOne('api/osd');
     expect(req.request.method).toBe('POST');
     expect(req.request.body).toEqual(post_data);
@@ -173,4 +174,10 @@ describe('OsdService', () => {
     const req = httpTesting.expectOne('api/osd/1/devices');
     expect(req.request.method).toBe('GET');
   });
+
+  it('should call getDeploymentOptions', () => {
+    service.getDeploymentOptions().subscribe();
+    const req = httpTesting.expectOne('ui-api/osd/deployment_options');
+    expect(req.request.method).toBe('GET');
+  });
 });
index c8f881d5e13f540641ed983760fa6d0d3710555f..10a0cf47f0887ed720af19d652f36d235f2e5b0c 100644 (file)
@@ -7,6 +7,7 @@ import { map } from 'rxjs/operators';
 
 import { CdDevice } from '../models/devices';
 import { InventoryDeviceType } from '../models/inventory-device-type.model';
+import { DeploymentOptions } from '../models/osd-deployment-options';
 import { OsdSettings } from '../models/osd-settings';
 import { SmartDataResponseV1 } from '../models/smart';
 import { DeviceService } from '../services/device.service';
@@ -16,6 +17,8 @@ import { DeviceService } from '../services/device.service';
 })
 export class OsdService {
   private path = 'api/osd';
+  private uiPath = 'ui-api/osd';
+
   osdDevices: InventoryDeviceType[] = [];
 
   osdRecvSpeedModalPriorities = {
@@ -65,11 +68,11 @@ export class OsdService {
 
   constructor(private http: HttpClient, private deviceService: DeviceService) {}
 
-  create(driveGroups: Object[]) {
+  create(driveGroups: Object[], trackingId: string, method = 'drive_groups') {
     const request = {
-      method: 'drive_groups',
+      method: method,
       data: driveGroups,
-      tracking_id: _.join(_.map(driveGroups, 'service_id'), ', ')
+      tracking_id: trackingId
     };
     return this.http.post(this.path, request, { observe: 'response' });
   }
@@ -104,6 +107,10 @@ export class OsdService {
     return this.http.post(`${this.path}/${id}/scrub?deep=${deep}`, null);
   }
 
+  getDeploymentOptions() {
+    return this.http.get<DeploymentOptions>(`${this.uiPath}/deployment_options`);
+  }
+
   getFlags() {
     return this.http.get(`${this.path}/flags`);
   }
index 80e3550cd68a5a6cee5da6585f5a7297003f375b..071b02e4a9d946cbc3780ae1b71a300a54be0c5d 100644 (file)
@@ -1,5 +1,9 @@
 @use './src/styles/vendor/variables' as vv;
 
+::ng-deep cd-wizard {
+  width: 15%;
+}
+
 .card-body {
   padding-left: 0;
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts
new file mode 100644 (file)
index 0000000..cae869e
--- /dev/null
@@ -0,0 +1,24 @@
+export enum OsdDeploymentOptions {
+  COST_CAPACITY = 'cost_capacity',
+  THROUGHPUT = 'throughput_optimized',
+  IOPS = 'iops_optimized'
+}
+
+export interface DeploymentOption {
+  name: OsdDeploymentOptions;
+  title: string;
+  desc: string;
+  capacity: number;
+  available: boolean;
+  hdd_used: number;
+  used: number;
+  nvme_used: number;
+  ssd_used: number;
+}
+
+export interface DeploymentOptions {
+  options: {
+    [key in OsdDeploymentOptions]: DeploymentOption;
+  };
+  recommended_option: OsdDeploymentOptions;
+}
index d36fec539360874e7e9c0086d095f7c0ea6aea71..3ead6efa535125fdfb8baff968f239a7623206e6 100644 (file)
@@ -90,3 +90,42 @@ mark {
 .border-success {
   border-left: 4px solid vv.$success;
 }
+
+.vertical-line {
+  border-left: 1px solid vv.$gray-400;
+}
+
+.accordion {
+  .card {
+    border: 0;
+  }
+
+  .card-header {
+    border: 0;
+    border-bottom: 3px solid vv.$white;
+    padding-left: 0;
+
+    .btn:focus,
+    .btn.focus {
+      box-shadow: none;
+    }
+
+    button.dropdown-toggle {
+      position: relative;
+
+      &::after {
+        border: 0;
+        content: '\f054';
+        font-family: 'ForkAwesome';
+        font-size: 1rem;
+        position: absolute;
+        right: 20px;
+        transition: transform 0.3s ease-in-out;
+      }
+
+      &[aria-expanded='true']::after {
+        transform: rotate(90deg);
+      }
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/services/osd.py b/src/pybind/mgr/dashboard/services/osd.py
new file mode 100644 (file)
index 0000000..12db733
--- /dev/null
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+from enum import Enum
+
+
+class OsdDeploymentOptions(str, Enum):
+    COST_CAPACITY = 'cost_capacity'
+    THROUGHPUT = 'throughput_optimized'
+    IOPS = 'iops_optimized'
+
+
+class HostStorageSummary:
+    def __init__(self, name: str, title=None, desc=None, available=False,
+                 capacity=0, used=0, hdd_used=0, ssd_used=0, nvme_used=0):
+        self.name = name
+        self.title = title
+        self.desc = desc
+        self.available = available
+        self.capacity = capacity
+        self.used = used
+        self.hdd_used = hdd_used
+        self.ssd_used = ssd_used
+        self.nvme_used = nvme_used
+
+    def as_dict(self):
+        return self.__dict__
index 775714dcac1231cdf86c42db0cd230366520bb00..0132b5051c6b52aa50ba849e815b98b250c35f76 100644 (file)
@@ -5,11 +5,11 @@ from typing import Any, Dict, List, Optional
 from unittest import mock
 
 from ceph.deployment.drive_group import DeviceSelection, DriveGroupSpec  # type: ignore
-from ceph.deployment.service_spec import PlacementSpec  # type: ignore
+from ceph.deployment.service_spec import PlacementSpec
 
 from .. import mgr
-from ..controllers._version import APIVersion
 from ..controllers.osd import Osd, OsdUi
+from ..services.osd import OsdDeploymentOptions
 from ..tests import ControllerTestCase
 from ..tools import NotificationQueue, TaskManager
 from .helper import update_dict  # pylint: disable=import-error
@@ -346,19 +346,19 @@ class OsdTest(ControllerTestCase):
         ]
         inventory_host = create_invetory_host(devices_data)
         fake_client.inventory.list.return_value = [inventory_host]
-        self._get('/ui-api/osd/deployment_options', version=APIVersion(0, 1))
+        self._get('/ui-api/osd/deployment_options')
         self.assertStatus(200)
         res = self.json_body()
-        self.assertTrue(res['options']['cost-capacity']['available'])
-        assert res['recommended_option'] == 'cost-capacity'
+        self.assertTrue(res['options'][OsdDeploymentOptions.COST_CAPACITY]['available'])
+        assert res['recommended_option'] == OsdDeploymentOptions.COST_CAPACITY
 
         for data in devices_data:
             data['type'] = 'ssd'
         inventory_host = create_invetory_host(devices_data)
         fake_client.inventory.list.return_value = [inventory_host]
 
-        self._get('/ui-api/osd/deployment_options', version=APIVersion(0, 1))
+        self._get('/ui-api/osd/deployment_options')
         self.assertStatus(200)
         res = self.json_body()
-        self.assertFalse(res['options']['cost-capacity']['available'])
+        self.assertFalse(res['options'][OsdDeploymentOptions.COST_CAPACITY]['available'])
         self.assertIsNone(res['recommended_option'])