]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Add iSCSI Form UI
authorTiago Melo <tmelo@suse.com>
Tue, 15 Jan 2019 15:34:54 +0000 (15:34 +0000)
committerTiago Melo <tmelo@suse.com>
Tue, 5 Feb 2019 12:19:52 +0000 (12:19 +0000)
Signed-off-by: Tiago Melo <tmelo@suse.com>
18 files changed:
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts
src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf

index 3523d0b132c4f4cfbcbc3e1d01a9cc54fd9978c0..87664ea740edd50e5330588057dec1134c44b8a1 100644 (file)
@@ -1,6 +1,7 @@
 import { NgModule } from '@angular/core';
 import { ActivatedRouteSnapshot, RouterModule, Routes } from '@angular/router';
 
+import { IscsiTargetFormComponent } from './ceph/block/iscsi-target-form/iscsi-target-form.component';
 import { IscsiTargetListComponent } from './ceph/block/iscsi-target-list/iscsi-target-list.component';
 import { IscsiComponent } from './ceph/block/iscsi/iscsi.component';
 import { OverviewComponent as RbdMirroringComponent } from './ceph/block/mirroring/overview/overview.component';
@@ -193,7 +194,8 @@ const routes: Routes = [
             path: 'targets',
             data: { breadcrumbs: 'Targets' },
             children: [
-              { path: '', component: IscsiTargetListComponent }
+              { path: '', component: IscsiTargetListComponent },
+              { path: 'add', component: IscsiTargetFormComponent, data: { breadcrumbs: 'Add' } }
             ]
           }
         ]
index 651b1d5c1ab77f6cfd43af3606561660298aff35..d5bbbb9cc036e4055b46e344f94d41c409e20bf5 100644 (file)
@@ -13,6 +13,10 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip';
 
 import { SharedModule } from '../../shared/shared.module';
 import { IscsiTabsComponent } from './iscsi-tabs/iscsi-tabs.component';
+import { IscsiTargetDetailsComponent } from './iscsi-target-details/iscsi-target-details.component';
+import { IscsiTargetFormComponent } from './iscsi-target-form/iscsi-target-form.component';
+import { IscsiTargetImageSettingsModalComponent } from './iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component';
+import { IscsiTargetIqnSettingsModalComponent } from './iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component';
 import { IscsiTargetListComponent } from './iscsi-target-list/iscsi-target-list.component';
 import { IscsiComponent } from './iscsi/iscsi.component';
 import { MirroringModule } from './mirroring/mirroring.module';
@@ -26,7 +30,6 @@ import { RbdTrashListComponent } from './rbd-trash-list/rbd-trash-list.component
 import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-move-modal.component';
 import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal/rbd-trash-purge-modal.component';
 import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-trash-restore-modal.component';
-import { IscsiTargetDetailsComponent } from './iscsi-target-details/iscsi-target-details.component';
 
 @NgModule({
   entryComponents: [
@@ -35,7 +38,9 @@ import { IscsiTargetDetailsComponent } from './iscsi-target-details/iscsi-target
     RbdTrashMoveModalComponent,
     RbdTrashRestoreModalComponent,
     RbdTrashPurgeModalComponent,
-    IscsiTargetDetailsComponent
+    IscsiTargetDetailsComponent,
+    IscsiTargetImageSettingsModalComponent,
+    IscsiTargetIqnSettingsModalComponent
   ],
   imports: [
     CommonModule,
@@ -66,7 +71,10 @@ import { IscsiTargetDetailsComponent } from './iscsi-target-details/iscsi-target
     RbdImagesComponent,
     RbdTrashRestoreModalComponent,
     RbdTrashPurgeModalComponent,
-    IscsiTargetDetailsComponent
+    IscsiTargetDetailsComponent,
+    IscsiTargetFormComponent,
+    IscsiTargetImageSettingsModalComponent,
+    IscsiTargetIqnSettingsModalComponent
   ]
 })
 export class BlockModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.html
new file mode 100644 (file)
index 0000000..00b7b78
--- /dev/null
@@ -0,0 +1,549 @@
+<div class="col-sm-12 col-lg-6">
+  <form name="targetForm"
+        class="form-horizontal"
+        #formDir="ngForm"
+        [formGroup]="targetForm"
+        novalidate
+        *ngIf="targetForm">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title"
+            i18n>Create target</h3>
+      </div>
+
+      <div class="panel-body">
+        <!-- Target IQN -->
+        <div class="form-group"
+             [ngClass]="{'has-error': targetForm.showError('target_iqn', formDir)}">
+          <label class="control-label col-sm-3"
+                 for="target_iqn">
+            <ng-container i18n>Target IQN</ng-container>
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <div class="input-group">
+              <input class="form-control"
+                     type="text"
+                     id="target_iqn"
+                     name="target_iqn"
+                     formControlName="target_iqn" />
+              <span class="input-group-btn">
+                <button class="btn btn-default"
+                        id="ecp-info-button"
+                        type="button"
+                        (click)="targetSettingsModal()">
+                  <i class="fa fa-cogs fa-fw"
+                     aria-hidden="true"></i>
+                </button>
+              </span>
+            </div>
+
+            <span class="help-block"
+                  *ngIf="targetForm.showError('target_iqn', formDir, 'required')"
+                  i18n>This field is required.</span>
+
+            <span class="help-block"
+                  *ngIf="targetForm.showError('target_iqn', formDir, 'pattern')"
+                  i18n>IQN has wrong pattern.</span>
+
+            <span class="help-block"
+                  *ngIf="targetForm.showError('target_iqn', formDir, 'iqn')">
+              <ng-container i18n>An IQN has the following notation 'iqn.$year-$month.$reversedAddress:$definedName'</ng-container>
+              <br>
+              <ng-container i18n>For example: iqn.2016-06.org.dashboard:storage:disk.sn-a8675309</ng-container>
+              <br>
+              <a target="_blank"
+                 href="https://en.wikipedia.org/wiki/ISCSI#Addressing"
+                 i18n>More information</a>
+            </span>
+
+            <span class="help-block"
+                  *ngIf="hasAdvancedSettings(targetForm.getValue('target_controls'))"
+                  i18n>This target has modified advanced settings.</span>
+            <hr />
+          </div>
+        </div>
+
+        <!-- Portals -->
+        <div class="form-group"
+             [ngClass]="{'has-error': targetForm.showError('portals', formDir)}">
+          <label class="control-label col-sm-3"
+                 for="portals">
+            <ng-container i18n>Portals</ng-container>
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+
+            <ng-container *ngFor="let portal of portals.value; let i = index">
+              <div class="input-group cd-mb">
+                <input class="form-control"
+                       type="text"
+                       [value]="portal"
+                       disabled />
+                <span class="input-group-btn">
+                  <button class="btn btn-default"
+                          type="button"
+                          (click)="removePortal(i, portal)">
+                    <i class="fa fa-remove fa-fw"
+                       aria-hidden="true"></i>
+                  </button>
+                </span>
+              </div>
+            </ng-container>
+
+            <span class="help-block"
+                  *ngIf="targetForm.showError('portals', formDir, 'minGateways')"
+                  i18n>At least {{ minimum_gateways }} gateways are required.</span>
+
+            <div class="row">
+              <div class="col-md-12">
+                <cd-select [data]="portals.value"
+                           [options]="portalsSelections"
+                           [messages]="messages.portals"
+                           (selection)="onPortalSelection($event)"
+                           elemClass="btn btn-default pull-right">
+                  <i class="fa fa-fw fa-plus"></i>
+                  <ng-container i18n>Add portal</ng-container>
+                </cd-select>
+              </div>
+            </div>
+
+            <hr />
+          </div>
+        </div>
+
+        <!-- Images -->
+        <div class="form-group"
+             [ngClass]="{'has-error': targetForm.showError('disks', formDir)}">
+          <label class="control-label col-sm-3"
+                 for="disks"
+                 i18n>Images</label>
+          <div class="col-sm-9">
+            <ng-container *ngFor="let image of targetForm.getValue('disks'); let i = index">
+              <div class="input-group cd-mb">
+                <input class="form-control"
+                       type="text"
+                       [value]="image"
+                       disabled />
+                <span class="input-group-btn">
+                  <button class="btn btn-default"
+                          type="button"
+                          (click)="imageSettingsModal(image)">
+                    <i class="fa fa-cogs fa-fw"
+                       aria-hidden="true"></i>
+                  </button>
+                  <button class="btn btn-default"
+                          type="button"
+                          (click)="removeImage(i, image)">
+                    <i class="fa fa-remove fa-fw"
+                       aria-hidden="true"></i>
+                  </button>
+                </span>
+
+              </div>
+              <span class="help-block"
+                    *ngIf="hasAdvancedSettings(imagesSettings[image])"
+                    i18n>This image has modified settings.</span>
+            </ng-container>
+
+            <span class="help-block"
+                  *ngIf="targetForm.showError('disks', formDir, 'required')"
+                  i18n>At least 1 image is required.</span>
+
+            <div class="row">
+              <div class="col-md-12">
+                <cd-select [data]="disks.value"
+                           [options]="imagesSelections"
+                           [messages]="messages.images"
+                           (selection)="onImageSelection($event)"
+                           elemClass="btn btn-default pull-right">
+                  <i class="fa fa-fw fa-plus"></i>
+                  <ng-container i18n>Add image</ng-container>
+                </cd-select>
+              </div>
+            </div>
+
+            <hr />
+          </div>
+        </div>
+
+        <!-- Initiators -->
+        <div class="form-group">
+          <label class="control-label col-sm-3"
+                 for="initiators"
+                 i18n>Initiators</label>
+          <div class="col-sm-9"
+               formArrayName="initiators">
+            <div class="panel panel-default"
+                 *ngFor="let initiator of initiators.controls; let i = index"
+                 [formGroupName]="i">
+              <div class="panel-heading">
+                <ng-container i18n>Initiator</ng-container>: {{ initiator.getValue('client_iqn') }}
+                <button type="button"
+                        class="close"
+                        (click)="removeInitiator(i)">
+                  <i class="fa fa-remove fa-fw"></i>
+                </button>
+              </div>
+              <div class="panel-body">
+                <!-- Initiator: Name -->
+                <div class="form-group"
+                     [ngClass]="{'has-error': initiator.showError('client_iqn', formDir)}">
+                  <label class="control-label col-sm-3"
+                         for="client_iqn">
+                    <ng-container i18n>Client IQN</ng-container>
+                    <span class="required"></span>
+                  </label>
+                  <div class="col-sm-9">
+                    <input class="form-control"
+                           type="text"
+                           formControlName="client_iqn"
+                           (blur)="updatedInitiatorSelector()">
+
+                    <span class="help-block"
+                          *ngIf="initiator.showError('client_iqn', formDir, 'notUnique')"
+                          i18n>Initiator IQN needs to be unique.</span>
+
+                    <span class="help-block"
+                          *ngIf="initiator.showError('client_iqn', formDir, 'required')"
+                          i18n>This field is required.</span>
+
+                    <span class="help-block"
+                          *ngIf="initiator.showError('client_iqn', formDir, 'pattern')"
+                          i18n>IQN has wrong pattern.</span>
+                  </div>
+                </div>
+
+                <ng-container formGroupName="auth">
+                  <!-- Initiator: User -->
+                  <div class="form-group"
+                       [ngClass]="{'has-error': initiator.showError('user', formDir)}">
+                    <label class="control-label col-sm-3"
+                           for="user"
+                           i18n>User</label>
+                    <div class="col-sm-9">
+                      <input id="user"
+                             class="form-control"
+                             formControlName="user"
+                             type="text">
+                      <span class="help-block"
+                            *ngIf="initiator.showError('user', formDir, 'required')"
+                            i18n>This field is required.</span>
+
+                      <span class="help-block"
+                            *ngIf="initiator.showError('user', formDir, 'pattern')"
+                            i18n>Usernames must have a length of 8 to 64 characters and
+                        can only contain letters, '.', '@', '-', '_' or ':'.</span>
+                    </div>
+                  </div>
+
+                  <!-- Initiator: Password -->
+                  <div class="form-group"
+                       [ngClass]="{'has-error': initiator.showError('password', formDir)}">
+                    <label class="control-label col-sm-3"
+                           for="password"
+                           i18n>Password</label>
+                    <div class="col-sm-9">
+                      <div class="input-group">
+                        <input id="password"
+                               class="form-control"
+                               formControlName="password"
+                               type="password">
+
+                        <span class="input-group-btn">
+                          <button type="button"
+                                  class="btn btn-default"
+                                  cdPasswordButton="password">
+                          </button>
+                          <button type="button"
+                                  class="btn btn-default"
+                                  cdCopy2ClipboardButton="password">
+                          </button>
+                        </span>
+                      </div>
+                      <span class="help-block"
+                            *ngIf="initiator.showError('password', formDir, 'required')"
+                            i18n>This field is required.</span>
+
+                      <span class="help-block"
+                            *ngIf="initiator.showError('password', formDir, 'pattern')"
+                            i18n>Passwords must have a length of 12 to 16 characters
+                        and can only contain letters, '@', '-' or '_'.</span>
+                    </div>
+                  </div>
+
+
+                  <!-- Initiator: mutual_user -->
+                  <div class="form-group"
+                       [ngClass]="{'has-error': initiator.showError('mutual_user', formDir)}">
+                    <label class="control-label col-sm-3"
+                           for="mutual_user">
+                      <ng-container i18n>Mutual User</ng-container>
+                    </label>
+                    <div class="col-sm-9">
+                      <input id="mutual_user"
+                             class="form-control"
+                             formControlName="mutual_user"
+                             type="text">
+
+                      <span class="help-block"
+                            *ngIf="initiator.showError('mutual_user', formDir, 'required')"
+                            i18n>This field is required.</span>
+
+                      <span class="help-block"
+                            *ngIf="initiator.showError('mutual_user', formDir, 'pattern')"
+                            i18n>Usernames must have a length of 8 to 64 characters and
+                        can only contain letters, '.', '@', '-', '_' or ':'.</span>
+                    </div>
+                  </div>
+
+                  <!-- Initiator: mutual_password -->
+                  <div class="form-group"
+                       [ngClass]="{'has-error': initiator.showError('mutual_password', formDir)}">
+                    <label class="control-label col-sm-3"
+                           for="mutual_password"
+                           i18n>Mutual Password</label>
+                    <div class="col-sm-9">
+                      <div class="input-group">
+                        <input id="mutual_password"
+                               class="form-control"
+                               formControlName="mutual_password"
+                               type="mutual_password">
+
+                        <span class="input-group-btn">
+                          <button type="button"
+                                  class="btn btn-default"
+                                  cdPasswordButton="mutual_password">
+                          </button>
+                          <button type="button"
+                                  class="btn btn-default"
+                                  cdCopy2ClipboardButton="mutual_password">
+                          </button>
+                        </span>
+                      </div>
+                      <span class="help-block"
+                            *ngIf="initiator.showError('mutual_password', formDir, 'required')"
+                            i18n>This field is required.</span>
+
+                      <span class="help-block"
+                            *ngIf="initiator.showError('mutual_password', formDir, 'pattern')"
+                            i18n>Passwords must have a length of 12 to 16 characters and
+                        can only contain letters, '@', '-' or '_'.</span>
+                    </div>
+                  </div>
+                </ng-container>
+
+                <!-- Initiator: Images -->
+                <div class="form-group"
+                     [ngClass]="{'has-error': initiator.showError('luns', formDir)}">
+                  <label class="control-label col-sm-3"
+                         for="luns"
+                         i18n>Images</label>
+                  <div class="col-sm-9">
+                    <ng-container *ngFor="let image of initiator.getValue('luns'); let i = index">
+                      <div class="input-group cd-mb">
+                        <input class="form-control"
+                               type="text"
+                               [value]="image"
+                               disabled />
+                        <span class="input-group-btn">
+                          <button class="btn btn-default"
+                                  type="button"
+                                  (click)="initiator.getValue('luns').splice(i, 1)">
+                            <i class="fa fa-remove fa-fw"
+                               aria-hidden="true"></i>
+                          </button>
+                        </span>
+                      </div>
+                    </ng-container>
+
+                    <span *ngIf="initiator.getValue('cdIsInGroup')"
+                          i18n>Initiator belongs to a group. Images will be configure in the group.</span>
+
+                    <div class="row"
+                         *ngIf="!initiator.getValue('cdIsInGroup')">
+                      <div class="col-md-12">
+                        <cd-select [data]="initiator.getValue('luns')"
+                                   [options]="imagesInitiatorSelections"
+                                   [messages]="messages.initiatorImage"
+                                   elemClass="btn btn-default pull-right">
+                          <i class="fa fa-fw fa-plus"></i>
+                          <ng-container i18n>Add image</ng-container>
+                        </cd-select>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div class="row">
+              <div class="col-md-12">
+                <span class="text-muted"
+                      *ngIf="initiators.controls.length === 0"
+                      i18n>No items added.</span>
+
+                <button (click)="addInitiator()"
+                        class="btn btn-default pull-right">
+                  <i class="fa fa-fw fa-plus"></i>
+                  <ng-container i18n>Add initiator</ng-container>
+                </button>
+              </div>
+            </div>
+
+            <hr />
+          </div>
+        </div>
+
+        <!-- Groups -->
+        <div class="form-group"
+             [ngClass]="{'has-error': targetForm.showError('groups', formDir)}">
+          <label class="control-label col-sm-3"
+                 for="initiators"
+                 i18n>Groups</label>
+          <div class="col-sm-9"
+               formArrayName="groups">
+            <div class="panel panel-default"
+                 *ngFor="let group of groups.controls; let i = index"
+                 [formGroupName]="i">
+              <div class="panel-heading">
+                <ng-container i18n>Group</ng-container>: {{ group.getValue('group_id') }}
+                <button type="button"
+                        class="close"
+                        (click)="groups.removeAt(i)">
+                  <i class="fa fa-remove fa-fw"></i>
+                </button>
+              </div>
+              <div class="panel-body">
+                <!-- Group: group_id -->
+                <div class="form-group">
+                  <label class="control-label col-sm-3"
+                         for="group_id">
+                    <ng-container i18n>Name</ng-container>
+                    <span class="required"></span>
+                  </label>
+                  <div class="col-sm-9">
+                    <input class="form-control"
+                           type="text"
+                           formControlName="group_id">
+                  </div>
+                </div>
+
+                <!-- Group: members -->
+                <div class="form-group"
+                     [ngClass]="{'has-error': group.showError('members', formDir)}">
+                  <label class="control-label col-sm-3"
+                         for="members">
+                    <ng-container i18n>Initiators</ng-container>
+                  </label>
+                  <div class="col-sm-9">
+                    <ng-container *ngFor="let member of group.getValue('members'); let i = index">
+                      <div class="input-group cd-mb">
+                        <input class="form-control"
+                               type="text"
+                               [value]="member"
+                               disabled />
+                        <span class="input-group-btn">
+                          <button class="btn btn-default"
+                                  type="button"
+                                  (click)="removeGroupInitiator(group, i)">
+                            <i class="fa fa-remove fa-fw"
+                               aria-hidden="true"></i>
+                          </button>
+                        </span>
+                      </div>
+                    </ng-container>
+
+                    <div class="row">
+                      <div class="col-md-12">
+                        <cd-select [data]="group.getValue('members')"
+                                   [options]="groupMembersSelections"
+                                   [messages]="messages.groupInitiator"
+                                   (selection)="onGroupMemberSelection($event)"
+                                   elemClass="btn btn-default pull-right">
+                          <i class="fa fa-fw fa-plus"></i>
+                          <ng-container i18n>Add initiator</ng-container>
+                        </cd-select>
+                      </div>
+                    </div>
+
+                    <hr />
+                  </div>
+                </div>
+
+                <!-- Group: disks -->
+                <div class="form-group"
+                     [ngClass]="{'has-error': group.showError('disks', formDir)}">
+                  <label class="control-label col-sm-3"
+                         for="disks">
+                    <ng-container i18n>Images</ng-container>
+                  </label>
+                  <div class="col-sm-9">
+                    <ng-container *ngFor="let disk of group.getValue('disks'); let i = index">
+                      <div class="input-group cd-mb">
+                        <input class="form-control"
+                               type="text"
+                               [value]="disk"
+                               disabled />
+                        <span class="input-group-btn">
+                          <button class="btn btn-default"
+                                  type="button"
+                                  (click)="group.getValue('disks').splice(i, 1)">
+                            <i class="fa fa-remove fa-fw"
+                               aria-hidden="true"></i>
+                          </button>
+                        </span>
+                      </div>
+                    </ng-container>
+
+                    <div class="row">
+                      <div class="col-md-12">
+                        <cd-select [data]="group.getValue('disks')"
+                                   [options]="groupDiskSelections"
+                                   [messages]="messages.initiatorImage"
+                                   elemClass="btn btn-default pull-right">
+                          <i class="fa fa-fw fa-plus"></i>
+                          <ng-container i18n>Add image</ng-container>
+                        </cd-select>
+                      </div>
+                    </div>
+
+                    <hr />
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div class="row">
+              <div class="col-md-12">
+                <span class="text-muted"
+                      *ngIf="groups.controls.length === 0"
+                      i18n>No items added.</span>
+
+                <button (click)="addGroup()"
+                        class="btn btn-default pull-right">
+                  <i class="fa fa-fw fa-plus"></i>
+                  <ng-container i18n>Add group</ng-container>
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+
+      </div>
+      <div class="panel-footer">
+        <div class="button-group text-right">
+          <cd-submit-button [form]="formDir"
+                            type="button"
+                            (submitAction)="submit()"
+                            i18n>Create target</cd-submit-button>
+
+          <button type="button"
+                  class="btn btn-sm btn-default"
+                  routerLink="/block/iscsi/targets"
+                  i18n>Back</button>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.scss
new file mode 100644 (file)
index 0000000..cebcc88
--- /dev/null
@@ -0,0 +1,3 @@
+.cd-mb {
+  margin-bottom: 10px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.spec.ts
new file mode 100644 (file)
index 0000000..faf35d0
--- /dev/null
@@ -0,0 +1,333 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../shared/shared.module';
+import { IscsiTargetFormComponent } from './iscsi-target-form.component';
+
+describe('IscsiTargetFormComponent', () => {
+  let component: IscsiTargetFormComponent;
+  let fixture: ComponentFixture<IscsiTargetFormComponent>;
+  let httpTesting: HttpTestingController;
+
+  const SETTINGS = {
+    config: { minimum_gateways: 2 },
+    disk_default_controls: {
+      hw_max_sectors: 1024,
+      osd_op_timeout: 30,
+      qfull_timeout: 5
+    },
+    target_default_controls: {
+      cmdsn_depth: 128,
+      dataout_timeout: 20,
+      immediate_data: 'Yes'
+    }
+  };
+
+  const LIST_TARGET = [
+    {
+      target_iqn: 'iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw',
+      portals: [{ host: 'node1', ip: '192.168.100.201' }],
+      disks: [{ pool: 'rbd', image: 'disk_1', controls: {} }],
+      clients: [
+        {
+          client_iqn: 'iqn.1994-05.com.redhat:rh7-client',
+          luns: [{ pool: 'rbd', image: 'disk_1' }],
+          auth: {
+            user: 'myiscsiusername',
+            password: 'myiscsipassword',
+            mutual_user: null,
+            mutual_password: null
+          }
+        }
+      ],
+      groups: [],
+      target_controls: {}
+    }
+  ];
+
+  const PORTALS = [
+    { name: 'node1', ip_addresses: ['192.168.100.201', '10.0.2.15'] },
+    { name: 'node2', ip_addresses: ['192.168.100.202'] }
+  ];
+
+  const RBD_LIST = [
+    { status: 0, value: [], pool_name: 'ganesha' },
+    {
+      status: 0,
+      value: [
+        {
+          size: 96636764160,
+          obj_size: 4194304,
+          num_objs: 23040,
+          order: 22,
+          block_name_prefix: 'rbd_data.148162fb31a8',
+          name: 'disk_1',
+          id: '148162fb31a8',
+          pool_name: 'rbd',
+          features: 61,
+          features_name: ['deep-flatten', 'exclusive-lock', 'fast-diff', 'layering', 'object-map'],
+          timestamp: '2019-01-18T10:44:26Z',
+          stripe_count: 1,
+          stripe_unit: 4194304,
+          data_pool: null,
+          parent: null,
+          snapshots: [],
+          total_disk_usage: 0,
+          disk_usage: 0
+        },
+        {
+          size: 119185342464,
+          obj_size: 4194304,
+          num_objs: 28416,
+          order: 22,
+          block_name_prefix: 'rbd_data.14b292cee6cb',
+          name: 'disk_2',
+          id: '14b292cee6cb',
+          pool_name: 'rbd',
+          features: 61,
+          features_name: ['deep-flatten', 'exclusive-lock', 'fast-diff', 'layering', 'object-map'],
+          timestamp: '2019-01-18T10:45:56Z',
+          stripe_count: 1,
+          stripe_unit: 4194304,
+          data_pool: null,
+          parent: null,
+          snapshots: [],
+          total_disk_usage: 0,
+          disk_usage: 0
+        }
+      ],
+      pool_name: 'rbd'
+    }
+  ];
+
+  configureTestBed(
+    {
+      declarations: [IscsiTargetFormComponent],
+      imports: [
+        SharedModule,
+        ReactiveFormsModule,
+        HttpClientTestingModule,
+        RouterTestingModule,
+        ToastModule.forRoot()
+      ],
+      providers: [i18nProviders]
+    },
+    true
+  );
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(IscsiTargetFormComponent);
+    component = fixture.componentInstance;
+    httpTesting = TestBed.get(HttpTestingController);
+    fixture.detectChanges();
+
+    httpTesting.expectOne('ui-api/iscsi/settings').flush(SETTINGS);
+    httpTesting.expectOne('ui-api/iscsi/portals').flush(PORTALS);
+    httpTesting.expectOne('api/summary').flush({});
+    httpTesting.expectOne('api/block/image').flush(RBD_LIST);
+    httpTesting.expectOne('api/iscsi/target').flush(LIST_TARGET);
+    httpTesting.verify();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should only show images not used in other targets', () => {
+    expect(component.imagesAll).toEqual(['rbd/disk_2']);
+    expect(component.imagesSelections).toEqual([
+      { description: '', name: 'rbd/disk_2', selected: false }
+    ]);
+  });
+
+  it('should generate portals selectOptions', () => {
+    expect(component.portalsSelections).toEqual([
+      { description: '', name: 'node1:192.168.100.201', selected: false },
+      { description: '', name: 'node1:10.0.2.15', selected: false },
+      { description: '', name: 'node2:192.168.100.202', selected: false }
+    ]);
+  });
+
+  it('should create the form', () => {
+    expect(component.targetForm.value).toEqual({
+      disks: [],
+      groups: [],
+      initiators: [],
+      portals: [],
+      target_controls: {},
+      target_iqn: component.targetForm.value.target_iqn
+    });
+  });
+
+  it('should prepare data when selecting an image', () => {
+    expect(component.imagesInitiatorSelections).toEqual([]);
+    expect(component.groupDiskSelections).toEqual([]);
+    expect(component.imagesSettings).toEqual({});
+
+    component.onImageSelection({ option: { name: 'rbd/disk_1', selected: true } });
+
+    expect(component.imagesInitiatorSelections).toEqual([
+      { description: '', name: 'rbd/disk_1', selected: false }
+    ]);
+    expect(component.groupDiskSelections).toEqual([
+      { description: '', name: 'rbd/disk_1', selected: false }
+    ]);
+    expect(component.imagesSettings).toEqual({ 'rbd/disk_1': {} });
+  });
+
+  it('should clean data when removing an image', () => {
+    component.onImageSelection({ option: { name: 'rbd/disk_1', selected: true } });
+    component.addGroup();
+    component.groups.controls[0].patchValue({
+      group_id: 'foo',
+      disks: ['rbd/disk_1']
+    });
+
+    expect(component.groups.controls[0].value).toEqual({
+      disks: ['rbd/disk_1'],
+      group_id: 'foo',
+      members: []
+    });
+
+    component.onImageSelection({ option: { name: 'rbd/disk_1', selected: false } });
+
+    expect(component.groups.controls[0].value).toEqual({ disks: [], group_id: 'foo', members: [] });
+    expect(component.imagesInitiatorSelections).toEqual([]);
+    expect(component.groupDiskSelections).toEqual([]);
+    expect(component.imagesSettings).toEqual({ 'rbd/disk_1': {} });
+  });
+
+  describe('should test initiators', () => {
+    beforeEach(() => {
+      component.addInitiator();
+      component.initiators.controls[0].patchValue({
+        client_iqn: 'iqn.initiator'
+      });
+      component.updatedInitiatorSelector();
+    });
+
+    it('should prepare data when creating an initiator', () => {
+      expect(component.initiators.controls.length).toBe(1);
+      expect(component.initiators.controls[0].value).toEqual({
+        auth: { mutual_password: '', mutual_user: '', password: '', user: '' },
+        cdIsInGroup: false,
+        client_iqn: 'iqn.initiator',
+        luns: []
+      });
+      expect(component.groupMembersSelections).toEqual([
+        { description: '', name: 'iqn.initiator', selected: false }
+      ]);
+    });
+
+    it('should update data when changing an initiator name', () => {
+      expect(component.groupMembersSelections).toEqual([
+        { description: '', name: 'iqn.initiator', selected: false }
+      ]);
+
+      component.initiators.controls[0].patchValue({
+        client_iqn: 'iqn.initiator_new'
+      });
+      component.updatedInitiatorSelector();
+
+      expect(component.groupMembersSelections).toEqual([
+        { description: '', name: 'iqn.initiator_new', selected: false }
+      ]);
+    });
+
+    it('should clean data when removing an initiator', () => {
+      component.addGroup();
+      component.groups.controls[0].patchValue({
+        group_id: 'foo',
+        members: ['iqn.initiator']
+      });
+
+      expect(component.groups.controls[0].value).toEqual({
+        disks: [],
+        group_id: 'foo',
+        members: ['iqn.initiator']
+      });
+
+      component.removeInitiator(0);
+
+      expect(component.groups.controls[0].value).toEqual({
+        disks: [],
+        group_id: 'foo',
+        members: []
+      });
+      expect(component.groupMembersSelections).toEqual([]);
+    });
+
+    it('should remove images in the initiator when added in a group', () => {
+      component.initiators.controls[0].patchValue({
+        luns: ['rbd/disk_1']
+      });
+      expect(component.initiators.controls[0].value).toEqual({
+        auth: { mutual_password: '', mutual_user: '', password: '', user: '' },
+        cdIsInGroup: false,
+        client_iqn: 'iqn.initiator',
+        luns: ['rbd/disk_1']
+      });
+
+      component.addGroup();
+      component.groups.controls[0].patchValue({
+        group_id: 'foo',
+        members: ['iqn.initiator']
+      });
+      component.onGroupMemberSelection({
+        option: {
+          name: 'iqn.initiator',
+          selected: true
+        }
+      });
+
+      expect(component.initiators.controls[0].value).toEqual({
+        auth: { mutual_password: '', mutual_user: '', password: '', user: '' },
+        cdIsInGroup: true,
+        client_iqn: 'iqn.initiator',
+        luns: []
+      });
+    });
+  });
+
+  it('should generate the request data', () => {
+    component.onImageSelection({ option: { name: 'rbd/disk_1', selected: true } });
+    component.portals.setValue(['node1:192.168.100.201', 'node2:192.168.100.202']);
+    component.addInitiator();
+    component.initiators.controls[0].patchValue({
+      client_iqn: 'iqn.initiator'
+    });
+    component.addGroup();
+    component.groups.controls[0].patchValue({
+      group_id: 'foo',
+      members: ['iqn.initiator'],
+      disks: ['rbd/disk_1']
+    });
+
+    component.submit();
+
+    const req = httpTesting.expectOne('api/iscsi/target');
+    expect(req.request.method).toBe('POST');
+    expect(req.request.body).toEqual({
+      clients: [
+        {
+          auth: { mutual_password: null, mutual_user: null, password: null, user: null },
+          cdIsInGroup: false,
+          client_iqn: 'iqn.initiator',
+          luns: []
+        }
+      ],
+      disks: [],
+      groups: [
+        { disks: [{ image: 'disk_1', pool: 'rbd' }], group_id: 'foo', members: ['iqn.initiator'] }
+      ],
+      portals: [{ host: 'node1', ip: '192.168.100.201' }, { host: 'node2', ip: '192.168.100.202' }],
+      target_controls: {},
+      target_iqn: component.targetForm.value.target_iqn
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts
new file mode 100644 (file)
index 0000000..fd12c6e
--- /dev/null
@@ -0,0 +1,463 @@
+import { Component } from '@angular/core';
+import { FormArray, FormControl, Validators } from '@angular/forms';
+import { Router } from '@angular/router';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import * as _ from 'lodash';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
+import { forkJoin } from 'rxjs';
+
+import { IscsiService } from '../../../shared/api/iscsi.service';
+import { RbdService } from '../../../shared/api/rbd.service';
+import { SelectMessages } from '../../../shared/components/select/select-messages.model';
+import { SelectOption } from '../../../shared/components/select/select-option.model';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../shared/forms/cd-validators';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { IscsiTargetImageSettingsModalComponent } from '../iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component';
+import { IscsiTargetIqnSettingsModalComponent } from '../iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component';
+
+@Component({
+  selector: 'cd-iscsi-target-form',
+  templateUrl: './iscsi-target-form.component.html',
+  styleUrls: ['./iscsi-target-form.component.scss']
+})
+export class IscsiTargetFormComponent {
+  targetForm: CdFormGroup;
+  modalRef: BsModalRef;
+  minimum_gateways = 1;
+  target_default_controls: any;
+  disk_default_controls: any;
+
+  imagesAll: any[];
+  imagesSelections: SelectOption[];
+  portalsSelections: SelectOption[] = [];
+  imagesInitiatorSelections: SelectOption[] = [];
+  groupDiskSelections: SelectOption[] = [];
+  groupMembersSelections: SelectOption[] = [];
+  imagesSettings: any = {};
+  messages = {
+    portals: new SelectMessages(
+      { noOptions: this.i18n('There are no portals available.') },
+      this.i18n
+    ),
+    images: new SelectMessages(
+      { noOptions: this.i18n('There are no images available.') },
+      this.i18n
+    ),
+    initiatorImage: new SelectMessages(
+      {
+        noOptions: this.i18n(
+          'There are no images available. Please make sure you add an image to the target.'
+        )
+      },
+      this.i18n
+    ),
+    groupInitiator: new SelectMessages(
+      {
+        noOptions: this.i18n(
+          'There are no initiators available. Please make sure you add an initiator to the target.'
+        )
+      },
+      this.i18n
+    )
+  };
+
+  IQN_REGEX = /^iqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+)*$/;
+  USER_REGEX = /[\w\.:@_-]{8,64}/;
+  PASSWORD_REGEX = /[\w@\-_]{12,16}/;
+
+  constructor(
+    private iscsiService: IscsiService,
+    private modalService: BsModalService,
+    private rbdService: RbdService,
+    private router: Router,
+    private i18n: I18n,
+    private taskWrapper: TaskWrapperService
+  ) {
+    forkJoin([this.rbdService.list(), this.iscsiService.listTargets()]).subscribe((data: any[]) => {
+      const usedImages = _(data[1])
+        .flatMap((target) => target.disks)
+        .map((image) => `${image.pool}/${image.image}`)
+        .value();
+
+      this.imagesAll = _(data[0])
+        .flatMap((pool) => pool.value)
+        .map((image) => `${image.pool_name}/${image.name}`)
+        .filter((image) => usedImages.indexOf(image) === -1)
+        .value();
+
+      this.imagesSelections = this.imagesAll.map((image) => new SelectOption(false, image, ''));
+    });
+
+    this.iscsiService.portals().subscribe((result: any) => {
+      const portals: SelectOption[] = [];
+      result.forEach((portal) => {
+        portal.ip_addresses.forEach((ip) => {
+          portals.push(new SelectOption(false, portal.name + ':' + ip, ''));
+        });
+      });
+      this.portalsSelections = [...portals];
+    });
+
+    this.iscsiService.settings().subscribe((result: any) => {
+      this.minimum_gateways = result.config.minimum_gateways;
+      this.target_default_controls = result.target_default_controls;
+      this.disk_default_controls = result.disk_default_controls;
+      this.createForm();
+    });
+  }
+
+  createForm() {
+    this.targetForm = new CdFormGroup({
+      target_iqn: new FormControl('iqn.2001-07.com.ceph:' + Date.now(), {
+        validators: [Validators.required, Validators.pattern(this.IQN_REGEX)]
+      }),
+      target_controls: new FormControl({}),
+      portals: new FormControl([], {
+        validators: [
+          CdValidators.custom('minGateways', (value) => {
+            const gateways = _.uniq(value.map((elem) => elem.split(':')[0]));
+            return gateways.length < Math.max(1, this.minimum_gateways);
+          })
+        ]
+      }),
+      disks: new FormControl([]),
+      initiators: new FormArray([]),
+      groups: new FormArray([])
+    });
+  }
+
+  hasAdvancedSettings(settings: any) {
+    return Object.values(settings).length > 0;
+  }
+
+  // Portals
+  get portals() {
+    return this.targetForm.get('portals') as FormControl;
+  }
+
+  onPortalSelection($event) {
+    this.portals.setValue(this.portals.value);
+  }
+
+  removePortal(index: number, portal: string) {
+    this.portalsSelections.forEach((value) => {
+      if (value.name === portal) {
+        value.selected = false;
+      }
+    });
+
+    this.portals.value.splice(index, 1);
+    this.portals.setValue(this.portals.value);
+    return false;
+  }
+
+  // Images
+  get disks() {
+    return this.targetForm.get('disks') as FormControl;
+  }
+
+  removeImage(index: number, image: string) {
+    this.imagesSelections.forEach((value) => {
+      if (value.name === image) {
+        value.selected = false;
+      }
+    });
+    this.disks.value.splice(index, 1);
+    this.removeImageRefs(image);
+    return false;
+  }
+
+  removeImageRefs(name) {
+    this.initiators.controls.forEach((element) => {
+      const newImages = element.value.luns.filter((item) => item !== name);
+      element.get('luns').setValue(newImages);
+    });
+
+    this.groups.controls.forEach((element) => {
+      const newDisks = element.value.disks.filter((item) => item !== name);
+      element.get('disks').setValue(newDisks);
+    });
+
+    this.imagesInitiatorSelections = this.imagesInitiatorSelections.filter(
+      (item) => item.name !== name
+    );
+    this.groupDiskSelections = this.groupDiskSelections.filter((item) => item.name !== name);
+  }
+
+  onImageSelection($event) {
+    const option = $event.option;
+
+    if (option.selected) {
+      if (!this.imagesSettings[option.name]) {
+        this.imagesSettings[option.name] = {};
+      }
+      this.imagesInitiatorSelections.push(new SelectOption(false, option.name, ''));
+      this.groupDiskSelections.push(new SelectOption(false, option.name, ''));
+    } else {
+      this.removeImageRefs(option.name);
+    }
+
+    this.imagesInitiatorSelections = [...this.imagesInitiatorSelections];
+    this.groupDiskSelections = [...this.groupDiskSelections];
+  }
+
+  // Initiators
+  get initiators() {
+    return this.targetForm.get('initiators') as FormArray;
+  }
+
+  addInitiator() {
+    const fg = new CdFormGroup({
+      client_iqn: new FormControl('', {
+        validators: [
+          Validators.required,
+          CdValidators.custom('notUnique', (client_iqn) => {
+            const flattened = this.initiators.controls.reduce(function(accumulator, currentValue) {
+              return accumulator.concat(currentValue.value.client_iqn);
+            }, []);
+
+            return flattened.indexOf(client_iqn) !== flattened.lastIndexOf(client_iqn);
+          }),
+          Validators.pattern(this.IQN_REGEX)
+        ]
+      }),
+      auth: new CdFormGroup({
+        user: new FormControl(''),
+        password: new FormControl(''),
+        mutual_user: new FormControl(''),
+        mutual_password: new FormControl('')
+      }),
+      luns: new FormControl([]),
+      cdIsInGroup: new FormControl(false)
+    });
+
+    CdValidators.validateIf(
+      fg.get('user'),
+      () => fg.getValue('password') || fg.getValue('mutual_user') || fg.getValue('mutual_password'),
+      [Validators.required],
+      [Validators.pattern(this.USER_REGEX)],
+      [fg.get('password'), fg.get('mutual_user'), fg.get('mutual_password')]
+    );
+
+    CdValidators.validateIf(
+      fg.get('password'),
+      () => fg.getValue('user') || fg.getValue('mutual_user') || fg.getValue('mutual_password'),
+      [Validators.required],
+      [Validators.pattern(this.PASSWORD_REGEX)],
+      [fg.get('user'), fg.get('mutual_user'), fg.get('mutual_password')]
+    );
+
+    CdValidators.validateIf(
+      fg.get('mutual_user'),
+      () => fg.getValue('mutual_password'),
+      [Validators.required],
+      [Validators.pattern(this.USER_REGEX)],
+      [fg.get('user'), fg.get('password'), fg.get('mutual_password')]
+    );
+
+    CdValidators.validateIf(
+      fg.get('mutual_password'),
+      () => fg.getValue('mutual_user'),
+      [Validators.required],
+      [Validators.pattern(this.PASSWORD_REGEX)],
+      [fg.get('user'), fg.get('password'), fg.get('mutual_user')]
+    );
+
+    this.initiators.push(fg);
+
+    this.groupMembersSelections.push(new SelectOption(false, '', ''));
+    this.groupMembersSelections = [...this.groupMembersSelections];
+
+    return false;
+  }
+
+  removeInitiator(index) {
+    this.initiators.removeAt(index);
+
+    const removed: SelectOption[] = this.groupMembersSelections.splice(index, 1);
+    this.groupMembersSelections = [...this.groupMembersSelections];
+
+    this.groups.controls.forEach((element) => {
+      const newMembers = element.value.members.filter((item) => item !== removed[0].name);
+      element.get('members').setValue(newMembers);
+    });
+  }
+
+  updatedInitiatorSelector() {
+    // Validate all client_iqn
+    this.initiators.controls.forEach((control) => {
+      control.get('client_iqn').updateValueAndValidity({ emitEvent: false });
+    });
+
+    // Update Group Initiator Selector
+    this.groupMembersSelections.forEach((elem, index) => {
+      const oldName = elem.name;
+      elem.name = this.initiators.controls[index].value.client_iqn;
+
+      this.groups.controls.forEach((element) => {
+        const members = element.value.members;
+        const i = members.indexOf(oldName);
+
+        if (i !== -1) {
+          members[i] = elem.name;
+        }
+        element.get('members').setValue(members);
+      });
+    });
+    this.groupMembersSelections = [...this.groupMembersSelections];
+  }
+
+  // Groups
+  get groups() {
+    return this.targetForm.get('groups') as FormArray;
+  }
+
+  addGroup() {
+    this.groups.push(
+      new CdFormGroup({
+        group_id: new FormControl('', { validators: [Validators.required] }),
+        members: new FormControl([]),
+        disks: new FormControl([])
+      })
+    );
+    return false;
+  }
+
+  onGroupMemberSelection($event) {
+    const option = $event.option;
+
+    this.initiators.controls.forEach((element) => {
+      if (element.value.client_iqn === option.name) {
+        element.patchValue({ luns: [] });
+        element.get('cdIsInGroup').setValue(option.selected);
+      }
+    });
+  }
+  removeGroupInitiator(group, i) {
+    const name = group.getValue('members')[i];
+    group.getValue('members').splice(i, 1);
+
+    this.groupMembersSelections.forEach((value) => {
+      if (value.name === name) {
+        value.selected = false;
+      }
+    });
+    this.groupMembersSelections = [...this.groupMembersSelections];
+
+    this.onGroupMemberSelection({ option: new SelectOption(false, name, '') });
+  }
+
+  submit() {
+    const formValue = this.targetForm.value;
+
+    const request = {
+      target_iqn: this.targetForm.getValue('target_iqn'),
+      target_controls: this.targetForm.getValue('target_controls'),
+      portals: [],
+      disks: [],
+      clients: [],
+      groups: []
+    };
+
+    // Disks
+    formValue.disks.forEach((disk) => {
+      const imageSplit = disk.split('/');
+      request.disks.push({
+        pool: imageSplit[0],
+        image: imageSplit[1],
+        controls: this.imagesSettings[disk]
+      });
+    });
+
+    // Portals
+    formValue.portals.forEach((portal) => {
+      const portalSplit = portal.split(':');
+      request.portals.push({
+        host: portalSplit[0],
+        ip: portalSplit[1]
+      });
+    });
+
+    // Clients
+    formValue.initiators.forEach((initiator) => {
+      if (!initiator.auth.user) {
+        initiator.auth.user = null;
+      }
+      if (!initiator.auth.password) {
+        initiator.auth.password = null;
+      }
+      if (!initiator.auth.mutual_user) {
+        initiator.auth.mutual_user = null;
+      }
+      if (!initiator.auth.mutual_password) {
+        initiator.auth.mutual_password = null;
+      }
+
+      const newLuns = [];
+      initiator.luns.forEach((lun) => {
+        const imageSplit = lun.split('/');
+        newLuns.push({
+          pool: imageSplit[0],
+          image: imageSplit[1]
+        });
+      });
+
+      initiator.luns = newLuns;
+    });
+    request.clients = formValue.initiators;
+
+    // Groups
+    formValue.groups.forEach((group) => {
+      const newDisks = [];
+      group.disks.forEach((disk) => {
+        const imageSplit = disk.split('/');
+        newDisks.push({
+          pool: imageSplit[0],
+          image: imageSplit[1]
+        });
+      });
+
+      group.disks = newDisks;
+    });
+    request.groups = formValue.groups;
+
+    this.taskWrapper
+      .wrapTaskAroundCall({
+        task: new FinishedTask('iscsi/target/create', {
+          target_iqn: request.target_iqn
+        }),
+        call: this.iscsiService.createTarget(request)
+      })
+      .subscribe(
+        undefined,
+        () => {
+          this.targetForm.setErrors({ cdSubmitButton: true });
+        },
+        () => this.router.navigate(['/block/iscsi/targets'])
+      );
+  }
+
+  targetSettingsModal() {
+    const initialState = {
+      target_controls: this.targetForm.get('target_controls'),
+      target_default_controls: this.target_default_controls
+    };
+
+    this.modalRef = this.modalService.show(IscsiTargetIqnSettingsModalComponent, { initialState });
+  }
+
+  imageSettingsModal(image) {
+    const initialState = {
+      imagesSettings: this.imagesSettings,
+      image: image,
+      disk_default_controls: this.disk_default_controls
+    };
+
+    this.modalRef = this.modalService.show(IscsiTargetImageSettingsModalComponent, {
+      initialState
+    });
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html
new file mode 100644 (file)
index 0000000..27b13dc
--- /dev/null
@@ -0,0 +1,44 @@
+<cd-modal>
+  <ng-container class="modal-title">
+    <ng-container i18n>Settings</ng-container>&nbsp;
+    <small>{{ image }}</small>
+  </ng-container>
+
+  <ng-container class="modal-content">
+    <form name="settingsForm"
+          class="form"
+          #formDir="ngForm"
+          [formGroup]="settingsForm"
+          novalidate>
+      <div class="modal-body">
+        <p class="alert-warning"
+           i18n>Changing these parameters from their default values is usually not necessary.</p>
+
+        <div class="form-group row"
+             *ngFor="let setting of disk_default_controls | keyvalue"
+             [ngClass]="{'has-error': settingsForm.showError(setting.key, formDir)}">
+          <div class="col-sm-12">
+            <label class="control-label"
+                   for="{{ setting.key }}">{{ setting.key }}</label>
+            <input type="number"
+                   class="form-control"
+                   [formControlName]="setting.key">
+            <span class="help-block">{{ helpText[setting.key]?.help }}</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="modal-footer">
+        <div class="button-group text-right">
+          <cd-submit-button i18n
+                            [form]="settingsForm"
+                            (submitAction)="save()">Confirm</cd-submit-button>
+          <button i18n
+                  type="button"
+                  class="btn btn-sm btn-default"
+                  (click)="modalRef.hide()">Cancel</button>
+        </div>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..440736d
--- /dev/null
@@ -0,0 +1,56 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../shared/shared.module';
+import { IscsiTargetImageSettingsModalComponent } from './iscsi-target-image-settings-modal.component';
+
+describe('IscsiTargetImageSettingsModalComponent', () => {
+  let component: IscsiTargetImageSettingsModalComponent;
+  let fixture: ComponentFixture<IscsiTargetImageSettingsModalComponent>;
+
+  configureTestBed({
+    declarations: [IscsiTargetImageSettingsModalComponent],
+    imports: [SharedModule, ReactiveFormsModule, HttpClientTestingModule],
+    providers: [BsModalRef, i18nProviders]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(IscsiTargetImageSettingsModalComponent);
+    component = fixture.componentInstance;
+
+    component.imagesSettings = { 'rbd/disk_1': {} };
+    component.image = 'rbd/disk_1';
+    component.disk_default_controls = {
+      foo: 1,
+      bar: 2
+    };
+    component.ngOnInit();
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should fill the settingsForm', () => {
+    expect(component.settingsForm.value).toEqual({
+      foo: null,
+      bar: null
+    });
+  });
+
+  it('should save changes to imagesSettings', () => {
+    component.settingsForm.patchValue({ foo: 1234 });
+    expect(component.imagesSettings).toEqual({ 'rbd/disk_1': {} });
+    component.save();
+    expect(component.imagesSettings).toEqual({
+      'rbd/disk_1': {
+        foo: 1234
+      }
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.ts
new file mode 100644 (file)
index 0000000..df0d3d0
--- /dev/null
@@ -0,0 +1,49 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+
+import * as _ from 'lodash';
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { IscsiService } from '../../../shared/api/iscsi.service';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+
+@Component({
+  selector: 'cd-iscsi-target-image-settings-modal',
+  templateUrl: './iscsi-target-image-settings-modal.component.html',
+  styleUrls: ['./iscsi-target-image-settings-modal.component.scss']
+})
+export class IscsiTargetImageSettingsModalComponent implements OnInit {
+  image: string;
+  imagesSettings: any;
+  disk_default_controls: any;
+
+  settingsForm: CdFormGroup;
+  helpText: any;
+
+  constructor(public modalRef: BsModalRef, public iscsiService: IscsiService) {}
+
+  ngOnInit() {
+    const fg = {};
+    const currentSettings = this.imagesSettings[this.image];
+    this.helpText = this.iscsiService.imageAdvancedSettings;
+
+    _.forIn(this.disk_default_controls, (value, key) => {
+      fg[key] = new FormControl(currentSettings[key]);
+    });
+
+    this.settingsForm = new CdFormGroup(fg);
+  }
+
+  save() {
+    const settings = {};
+    _.forIn(this.settingsForm.value, (value, key) => {
+      if (!(value === '' || value === null)) {
+        settings[key] = value;
+      }
+    });
+
+    this.imagesSettings[this.image] = settings;
+    this.imagesSettings = { ...this.imagesSettings };
+    this.modalRef.hide();
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html
new file mode 100644 (file)
index 0000000..23a5697
--- /dev/null
@@ -0,0 +1,62 @@
+<cd-modal>
+  <ng-container class="modal-title"
+                i18n>Advanced Settings</ng-container>
+
+  <ng-container class="modal-content">
+    <form name="settingsForm"
+          class="form"
+          #formDir="ngForm"
+          [formGroup]="settingsForm"
+          novalidate>
+      <div class="modal-body">
+        <p class="alert-warning"
+           i18n>Changing these parameters from their default values is usually not necessary.</p>
+
+        <div class="form-group row"
+             *ngFor="let setting of settingsForm.controls | keyvalue"
+             [ngClass]="{'has-error': settingsForm.showError(setting.key, formDir)}">
+          <div class="col-sm-12">
+            <label class="control-label"
+                   for="{{ setting.key }}">{{ setting.key }}</label>
+            <input class="form-control"
+                   *ngIf="!isRadio(setting.key)"
+                   type="number"
+                   [formControlName]="setting.key">
+
+            <ng-container *ngIf="isRadio(setting.key)">
+              <br>
+              <div class="radio radio-inline">
+                <input type="radio"
+                       [id]="setting.key + 'Yes'"
+                       value="Yes"
+                       [formControlName]="setting.key">
+                <label [for]="setting.key + 'Yes'">Yes</label>
+              </div>
+              <div class="radio radio-inline">
+                <input type="radio"
+                       [id]="setting.key + 'No'"
+                       value="No"
+                       [formControlName]="setting.key">
+                <label [for]="setting.key + 'No'">No</label>
+              </div>
+            </ng-container>
+
+            <span class="help-block">{{ helpText[setting.key]?.help }}</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="modal-footer">
+        <div class="button-group text-right">
+          <cd-submit-button i18n
+                            [form]="settingsForm"
+                            (submitAction)="save()">Confirm</cd-submit-button>
+          <button i18n
+                  type="button"
+                  class="btn btn-sm btn-default"
+                  (click)="modalRef.hide()">Cancel</button>
+        </div>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..1862d6b
--- /dev/null
@@ -0,0 +1,52 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../shared/shared.module';
+import { IscsiTargetIqnSettingsModalComponent } from './iscsi-target-iqn-settings-modal.component';
+
+describe('IscsiTargetIqnSettingsModalComponent', () => {
+  let component: IscsiTargetIqnSettingsModalComponent;
+  let fixture: ComponentFixture<IscsiTargetIqnSettingsModalComponent>;
+
+  configureTestBed({
+    declarations: [IscsiTargetIqnSettingsModalComponent],
+    imports: [SharedModule, ReactiveFormsModule, HttpClientTestingModule],
+    providers: [BsModalRef, i18nProviders]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(IscsiTargetIqnSettingsModalComponent);
+    component = fixture.componentInstance;
+    component.target_controls = new FormControl({});
+    component.target_default_controls = {
+      cmdsn_depth: 1,
+      dataout_timeout: 2,
+      first_burst_length: 'Yes'
+    };
+    component.ngOnInit();
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should fill the settingsForm', () => {
+    expect(component.settingsForm.value).toEqual({
+      cmdsn_depth: null,
+      dataout_timeout: null,
+      first_burst_length: null
+    });
+  });
+
+  it('should save changes to target_controls', () => {
+    component.settingsForm.patchValue({ dataout_timeout: 1234 });
+    expect(component.target_controls.value).toEqual({});
+    component.save();
+    expect(component.target_controls.value).toEqual({ dataout_timeout: 1234 });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.ts
new file mode 100644 (file)
index 0000000..db0cdf3
--- /dev/null
@@ -0,0 +1,50 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+
+import * as _ from 'lodash';
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { IscsiService } from '../../../shared/api/iscsi.service';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+
+@Component({
+  selector: 'cd-iscsi-target-iqn-settings-modal',
+  templateUrl: './iscsi-target-iqn-settings-modal.component.html',
+  styleUrls: ['./iscsi-target-iqn-settings-modal.component.scss']
+})
+export class IscsiTargetIqnSettingsModalComponent implements OnInit {
+  target_controls: FormControl;
+  target_default_controls: any;
+
+  settingsForm: CdFormGroup;
+  helpText: any;
+
+  constructor(public modalRef: BsModalRef, public iscsiService: IscsiService) {}
+
+  ngOnInit() {
+    const fg = {};
+    this.helpText = this.iscsiService.targetAdvancedSettings;
+
+    _.forIn(this.target_default_controls, (value, key) => {
+      fg[key] = new FormControl(this.target_controls.value[key]);
+    });
+
+    this.settingsForm = new CdFormGroup(fg);
+  }
+
+  save() {
+    const settings = {};
+    _.forIn(this.settingsForm.controls, (control, key) => {
+      if (!(control.value === '' || control.value === null)) {
+        settings[key] = control.value;
+      }
+    });
+
+    this.target_controls.setValue(settings);
+    this.modalRef.hide();
+  }
+
+  isRadio(control) {
+    return ['Yes', 'No'].indexOf(this.target_default_controls[control]) !== -1;
+  }
+}
index 97f35fa7ae90786f30e7646c8754f3397ceaed7c..33edc9660ac667d8113ae515d139ba824bee5f6f 100644 (file)
           forceIdentifier="true"
           selectionType="single"
           (updateSelection)="updateSelection($event)">
+  <div class="table-actions btn-toolbar">
+    <cd-table-actions class="btn-group"
+                      [permission]="permissions.iscsi"
+                      [selection]="selection"
+                      [tableActions]="tableActions">
+    </cd-table-actions>
+  </div>
+
   <cd-iscsi-target-details cdTableDetail
                            *ngIf="selection.hasSingleSelection"
                            [selection]="selection"
index 136373d3aad2c7a94f8f474864b7a972b3f8f53a..299ef33deaa84d119e2f84f9a46cec10e8b7657c 100644 (file)
@@ -1,12 +1,22 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
 import { RouterTestingModule } from '@angular/router/testing';
 
 import { ToastModule } from 'ng2-toastr';
 import { TreeModule } from 'ng2-tree';
 import { TabsModule } from 'ngx-bootstrap/tabs';
+import { BehaviorSubject, of } from 'rxjs';
 
-import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import {
+  configureTestBed,
+  i18nProviders,
+  PermissionHelper
+} from '../../../../testing/unit-test-helper';
+import { IscsiService } from '../../../shared/api/iscsi.service';
+import { TableActionsComponent } from '../../../shared/datatable/table-actions/table-actions.component';
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { SummaryService } from '../../../shared/services/summary.service';
 import { TaskListService } from '../../../shared/services/task-list.service';
 import { SharedModule } from '../../../shared/shared.module';
 import { IscsiTabsComponent } from '../iscsi-tabs/iscsi-tabs.component';
@@ -16,6 +26,12 @@ import { IscsiTargetListComponent } from './iscsi-target-list.component';
 describe('IscsiTargetListComponent', () => {
   let component: IscsiTargetListComponent;
   let fixture: ComponentFixture<IscsiTargetListComponent>;
+  let summaryService: SummaryService;
+  let iscsiService: IscsiService;
+
+  const refresh = (data) => {
+    summaryService['summaryDataSource'].next(data);
+  };
 
   configureTestBed({
     imports: [
@@ -33,10 +49,278 @@ describe('IscsiTargetListComponent', () => {
   beforeEach(() => {
     fixture = TestBed.createComponent(IscsiTargetListComponent);
     component = fixture.componentInstance;
-    fixture.detectChanges();
+    summaryService = TestBed.get(SummaryService);
+    iscsiService = TestBed.get(IscsiService);
+
+    // this is needed because summaryService isn't being reset after each test.
+    summaryService['summaryDataSource'] = new BehaviorSubject(null);
+    summaryService['summaryData$'] = summaryService['summaryDataSource'].asObservable();
+
+    spyOn(iscsiService, 'status').and.returnValue(of({ available: true }));
   });
 
   it('should create', () => {
     expect(component).toBeTruthy();
   });
+
+  describe('after ngOnInit', () => {
+    beforeEach(() => {
+      spyOn(iscsiService, 'listTargets').and.callThrough();
+      fixture.detectChanges();
+    });
+
+    it('should load targets on init', () => {
+      refresh({});
+      expect(iscsiService.status).toHaveBeenCalled();
+      expect(iscsiService.listTargets).toHaveBeenCalled();
+    });
+
+    it('should not load targets on init because no data', () => {
+      refresh(undefined);
+      expect(iscsiService.listTargets).not.toHaveBeenCalled();
+    });
+
+    it('should call error function on init when summary service fails', () => {
+      spyOn(component.table, 'reset');
+      summaryService['summaryDataSource'].error(undefined);
+      expect(component.table.reset).toHaveBeenCalled();
+    });
+  });
+
+  describe('handling of executing tasks', () => {
+    let targets: any[];
+
+    const addTarget = (name) => {
+      const model: any = {
+        target_iqn: name,
+        portals: [{ host: 'node1', ip: '192.168.100.201' }],
+        disks: [{ pool: 'rbd', image: 'disk_1', controls: {} }],
+        clients: [
+          {
+            client_iqn: 'iqn.1994-05.com.redhat:rh7-client',
+            luns: [{ pool: 'rbd', image: 'disk_1' }],
+            auth: {
+              user: 'myiscsiusername',
+              password: 'myiscsipassword',
+              mutual_user: null,
+              mutual_password: null
+            }
+          }
+        ],
+        groups: [],
+        target_controls: {}
+      };
+      targets.push(model);
+    };
+
+    const addTask = (name: string, target_iqn: string) => {
+      const task = new ExecutingTask();
+      task.name = name;
+      switch (task.name) {
+        case 'iscsi/target/create':
+          task.metadata = {
+            target_iqn: target_iqn
+          };
+          break;
+        case 'iscsi/target/delete':
+          task.metadata = {
+            target_iqn: target_iqn
+          };
+          break;
+        default:
+          task.metadata = {
+            target_iqn: target_iqn
+          };
+          break;
+      }
+      summaryService.addRunningTask(task);
+    };
+
+    const expectTargetTasks = (target: any, executing: string) => {
+      expect(target.cdExecuting).toEqual(executing);
+    };
+
+    beforeEach(() => {
+      targets = [];
+      addTarget('iqn.a');
+      addTarget('iqn.b');
+      addTarget('iqn.c');
+
+      component.targets = targets;
+      refresh({ executing_tasks: [], finished_tasks: [] });
+      spyOn(iscsiService, 'listTargets').and.callFake(() => of(targets));
+      fixture.detectChanges();
+    });
+
+    it('should gets all targets without tasks', () => {
+      expect(component.targets.length).toBe(3);
+      expect(component.targets.every((target) => !target.cdExecuting)).toBeTruthy();
+    });
+
+    it('should add a new target from a task', () => {
+      addTask('iscsi/target/create', 'iqn.d');
+      expect(component.targets.length).toBe(4);
+      expectTargetTasks(component.targets[0], undefined);
+      expectTargetTasks(component.targets[1], undefined);
+      expectTargetTasks(component.targets[2], undefined);
+      expectTargetTasks(component.targets[3], 'Creating');
+    });
+
+    it('should show when an existing target is being modified', () => {
+      addTask('iscsi/target/delete', 'iqn.b');
+      expect(component.targets.length).toBe(3);
+      expectTargetTasks(component.targets[1], 'Deleting');
+    });
+  });
+
+  describe('show action buttons and drop down actions depending on permissions', () => {
+    let tableActions: TableActionsComponent;
+    let scenario: { fn; empty; single };
+    let permissionHelper: PermissionHelper;
+
+    const getTableActionComponent = (): TableActionsComponent => {
+      fixture.detectChanges();
+      return fixture.debugElement.query(By.directive(TableActionsComponent)).componentInstance;
+    };
+
+    beforeEach(() => {
+      permissionHelper = new PermissionHelper(component.permissions.iscsi, () =>
+        getTableActionComponent()
+      );
+      scenario = {
+        fn: () => tableActions.getCurrentButton().name,
+        single: 'Delete',
+        empty: 'Add'
+      };
+    });
+
+    describe('with all', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(1, 1, 1);
+      });
+
+      it(`shows 'Delete' for single selection else 'Add' as main action`, () =>
+        permissionHelper.testScenarios(scenario));
+
+      it('shows all actions', () => {
+        expect(tableActions.tableActions.length).toBe(2);
+        expect(tableActions.tableActions).toEqual(component.tableActions);
+      });
+    });
+
+    describe('with read, create and update', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(1, 1, 0);
+        scenario.single = 'Add';
+      });
+
+      it(`should always show 'Add'`, () => {
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows all actions except for 'Delete'`, () => {
+        expect(tableActions.tableActions.length).toBe(1);
+        component.tableActions.pop();
+        expect(tableActions.tableActions).toEqual(component.tableActions);
+      });
+    });
+
+    describe('with read, create and delete', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(1, 0, 1);
+      });
+
+      it(`shows 'Delete' for single selection else 'Add' as main action`, () => {
+        scenario.single = 'Delete';
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows 'Add' and 'Delete' actions`, () => {
+        expect(tableActions.tableActions.length).toBe(2);
+        expect(tableActions.tableActions).toEqual([
+          component.tableActions[0],
+          component.tableActions[1]
+        ]);
+      });
+    });
+
+    describe('with read, edit and delete', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 1, 1);
+      });
+
+      it(`shows always 'Delete' as main action`, () => {
+        scenario.empty = 'Delete';
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows 'Delete' action`, () => {
+        expect(tableActions.tableActions.length).toBe(1);
+        expect(tableActions.tableActions).toEqual([component.tableActions[1]]);
+      });
+    });
+
+    describe('with read and create', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(1, 0, 0);
+      });
+
+      it(`shows 'Add' for single selection and 'Add' as main action`, () => {
+        scenario.single = 'Add';
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows 'Add' actions`, () => {
+        expect(tableActions.tableActions.length).toBe(1);
+        expect(tableActions.tableActions).toEqual([component.tableActions[0]]);
+      });
+    });
+
+    describe('with read and edit', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 1, 0);
+      });
+
+      it(`shows no actions`, () => {
+        expect(tableActions.tableActions.length).toBe(0);
+        expect(tableActions.tableActions).toEqual([]);
+      });
+    });
+
+    describe('with read and delete', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 0, 1);
+      });
+
+      it(`shows always 'Delete' as main action`, () => {
+        scenario.single = 'Delete';
+        scenario.empty = 'Delete';
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows 'Delete' actions`, () => {
+        expect(tableActions.tableActions.length).toBe(1);
+        expect(tableActions.tableActions).toEqual([component.tableActions[1]]);
+      });
+    });
+
+    describe('with only read', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 0, 0);
+      });
+
+      it('shows no main action', () => {
+        permissionHelper.testScenarios({
+          fn: () => tableActions.getCurrentButton(),
+          single: undefined,
+          empty: undefined
+        });
+      });
+
+      it('shows no actions', () => {
+        expect(tableActions.tableActions.length).toBe(0);
+        expect(tableActions.tableActions).toEqual([]);
+      });
+    });
+  });
 });
index 21b1cb70571fb9d1f86b023a9a120181eea7d80c..91fe926518227d5cd21966c042fb2298cc23aa3f 100644 (file)
@@ -1,18 +1,23 @@
 import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
 
 import { I18n } from '@ngx-translate/i18n-polyfill';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
 import { Subscription } from 'rxjs';
 
 import { IscsiService } from '../../../shared/api/iscsi.service';
+import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { TableComponent } from '../../../shared/datatable/table/table.component';
 import { CellTemplate } from '../../../shared/enum/cell-template.enum';
+import { CdTableAction } from '../../../shared/models/cd-table-action';
 import { CdTableColumn } from '../../../shared/models/cd-table-column';
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { FinishedTask } from '../../../shared/models/finished-task';
 import { Permissions } from '../../../shared/models/permissions';
 import { CephReleaseNamePipe } from '../../../shared/pipes/ceph-release-name.pipe';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { SummaryService } from '../../../shared/services/summary.service';
 import { TaskListService } from '../../../shared/services/task-list.service';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
 
 @Component({
   selector: 'cd-iscsi-target-list',
@@ -36,15 +41,40 @@ export class IscsiTargetListComponent implements OnInit, OnDestroy {
   tableActions: CdTableAction[];
   targets = [];
 
+  builders = {
+    'iscsi/target/create': (metadata) => {
+      return {
+        target_iqn: metadata['target_iqn']
+      };
+    }
+  };
+
   constructor(
     private authStorageService: AuthStorageService,
     private i18n: I18n,
     private iscsiService: IscsiService,
     private taskListService: TaskListService,
     private cephReleaseNamePipe: CephReleaseNamePipe,
-    private summaryservice: SummaryService
+    private summaryservice: SummaryService,
+    private modalService: BsModalService,
+    private taskWrapper: TaskWrapperService
   ) {
     this.permissions = this.authStorageService.getPermissions();
+
+    this.tableActions = [
+      {
+        permission: 'create',
+        icon: 'fa-plus',
+        routerLink: () => '/block/iscsi/targets/add',
+        name: this.i18n('Add')
+      },
+      {
+        permission: 'delete',
+        icon: 'fa-times',
+        click: () => this.deleteIscsiTargetModal(),
+        name: this.i18n('Delete')
+      }
+    ];
   }
 
   ngOnInit() {
@@ -76,9 +106,9 @@ export class IscsiTargetListComponent implements OnInit, OnDestroy {
           (resp) => this.prepareResponse(resp),
           (targets) => (this.targets = targets),
           () => this.onFetchError(),
-          () => false,
-          () => false,
-          undefined
+          this.taskFilter,
+          this.itemFilter,
+          this.builders
         );
 
         this.iscsiService.settings().subscribe((settings: any) => {
@@ -112,7 +142,32 @@ export class IscsiTargetListComponent implements OnInit, OnDestroy {
     this.table.reset(); // Disable loading indicator.
   }
 
+  itemFilter(entry, task) {
+    return entry.target_iqn === task.metadata['target_iqn'];
+  }
+
+  taskFilter(task) {
+    return ['iscsi/target/create', 'iscsi/target/delete'].includes(task.name);
+  }
+
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
+
+  deleteIscsiTargetModal() {
+    const target_iqn = this.selection.first().target_iqn;
+
+    this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+      initialState: {
+        itemDescription: this.i18n('iSCSI'),
+        submitActionObservable: () =>
+          this.taskWrapper.wrapTaskAroundCall({
+            task: new FinishedTask('iscsi/target/delete', {
+              target_iqn: target_iqn
+            }),
+            call: this.iscsiService.deleteTarget(target_iqn)
+          })
+      }
+    });
+  }
 }
index 416e730353ff4ae915de868ff0b6dd8049b11976..666cc701a5866c551b713cf163f662e575372de8 100644 (file)
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
           <context context-type="linenumber">128</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">120</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">341</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">479</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/iscsi/iscsi.component.html</context>
-          <context context-type="linenumber">7</context>
+          <context context-type="linenumber">9</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/mirroring/overview/overview.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/iscsi/iscsi.component.html</context>
-          <context context-type="linenumber">1</context>
+          <context context-type="linenumber">3</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/mirroring/overview/overview.component.html</context>
           <context context-type="sourcefile">app/shared/components/error-panel/error-panel.component.html</context>
           <context context-type="linenumber">27</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">544</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
           <context context-type="linenumber">303</context>
           <context context-type="linenumber">118</context>
         </context-group>
         <note priority="1" from="description">X total</note>
-      </trans-unit><trans-unit id="beb1391a148bd032914f49a2665dcc414a5d56d0" datatype="html">
-        <source>{VAR_SELECT, select, editing {Edit} cloning {Clone} copying {Copy} other {Add} }</source>
+      </trans-unit><trans-unit id="121cc5391cd2a5115bc2b3160379ee5b36cd7716" datatype="html">
+        <source>Settings</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">10</context>
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html</context>
+          <context context-type="linenumber">3</context>
         </context-group>
-      </trans-unit><trans-unit id="52dd89f49fc440660cbbb3665b88d80f5baa7437" datatype="html">
-        <source>{VAR_SELECT, select, cloning {Clone from} copying {Copy from} other {Parent} }</source>
+      </trans-unit><trans-unit id="9e515f954730279c31d5301f02479666d6264e8b" datatype="html">
+        <source>Changing these parameters from their default values is usually not necessary.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">20</context>
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html</context>
+          <context context-type="linenumber">15</context>
         </context-group>
-      </trans-unit><trans-unit id="cff1428d10d59d14e45edec3c735a27b5482db59" datatype="html">
-        <source>Name</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">36</context>
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html</context>
+          <context context-type="linenumber">13</context>
         </context-group>
+      </trans-unit><trans-unit id="68e710782ccb5398b3acb8844caf0b199da2c3da" datatype="html">
+        <source>Confirm</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/cluster/configuration/configuration-form/configuration-form.component.html</context>
-          <context context-type="linenumber">18</context>
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html</context>
+          <context context-type="linenumber">35</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html</context>
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html</context>
+          <context context-type="linenumber">53</context>
+        </context-group>
+      </trans-unit><trans-unit id="d7b35c384aecd25a516200d6921836374613dfe7" datatype="html">
+        <source>Cancel</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html</context>
+          <context context-type="linenumber">39</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html</context>
+          <context context-type="linenumber">57</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html</context>
+          <context context-type="linenumber">38</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/shared/components/confirmation-modal/confirmation-modal.component.html</context>
           <context context-type="linenumber">21</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">26</context>
+          <context context-type="sourcefile">app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.html</context>
+          <context context-type="linenumber">38</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html</context>
-          <context context-type="linenumber">42</context>
+          <context context-type="sourcefile">app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html</context>
+          <context context-type="linenumber">91</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/core/auth/role-form/role-form.component.html</context>
-          <context context-type="linenumber">19</context>
+          <context context-type="sourcefile">app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html</context>
+          <context context-type="linenumber">34</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html</context>
-          <context context-type="linenumber">8</context>
+          <context context-type="sourcefile">app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.html</context>
+          <context context-type="linenumber">25</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/cluster/configuration/configuration-details/configuration-details.component.html</context>
-          <context context-type="linenumber">8</context>
+          <context context-type="sourcefile">app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html</context>
+          <context context-type="linenumber">44</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
-          <context context-type="linenumber">13</context>
+          <context context-type="sourcefile">app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html</context>
+          <context context-type="linenumber">45</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html</context>
-          <context context-type="linenumber">23</context>
+          <context context-type="sourcefile">app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html</context>
+          <context context-type="linenumber">44</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html</context>
+          <context context-type="linenumber">45</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html</context>
+          <context context-type="linenumber">110</context>
+        </context-group>
+      </trans-unit><trans-unit id="339878da255ab55447c43afef8d9b2f9753bf5f6" datatype="html">
+        <source>Advanced Settings</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html</context>
+          <context context-type="linenumber">3</context>
+        </context-group>
+      </trans-unit><trans-unit id="a01e6937a5d1ee040a02416eed34544c4ea61e38" datatype="html">
+        <source>Create target</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">539</context>
+        </context-group>
+      </trans-unit><trans-unit id="1406c2fb12a20c1528b19bcc5e24a6a2386167f3" datatype="html">
+        <source>Target IQN</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">20</context>
         </context-group>
       </trans-unit><trans-unit id="7cbdabcece469fab89cfa687ab152bca18b97498" datatype="html">
         <source>This field is required.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">43</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">209</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">231</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">266</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">291</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">326</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
           <context context-type="linenumber">49</context>
           <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html</context>
           <context context-type="linenumber">58</context>
         </context-group>
+      </trans-unit><trans-unit id="5fe42339be910372fa689f559155631862d218e8" datatype="html">
+        <source>IQN has wrong pattern.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">47</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">213</context>
+        </context-group>
+      </trans-unit><trans-unit id="47d1bfe4f5b3f292e1202dfe691195b10cb99500" datatype="html">
+        <source>An IQN has the following notation &apos;iqn.$year-$month.$reversedAddress:$definedName&apos;</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">51</context>
+        </context-group>
+      </trans-unit><trans-unit id="c8ada4b53396d8366db00a435acc61d53d857047" datatype="html">
+        <source>For example: iqn.2016-06.org.dashboard:storage:disk.sn-a8675309</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">53</context>
+        </context-group>
+      </trans-unit><trans-unit id="e60c11e1b1dfbbeda577364b8de39ded2d796c5e" datatype="html">
+        <source>More information</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">57</context>
+        </context-group>
+      </trans-unit><trans-unit id="9b1aa85dfc6849196e64060db02c5410de69b7a1" datatype="html">
+        <source>This target has modified advanced settings.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">62</context>
+        </context-group>
+      </trans-unit><trans-unit id="6990ad8d6182662e864495ac31c3758cda1c7a28" datatype="html">
+        <source>Portals</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">72</context>
+        </context-group>
+      </trans-unit><trans-unit id="c3638c01b6c34066438909713ec96087c813fc7e" datatype="html">
+        <source>At least <x id="INTERPOLATION" equiv-text="{{ minimum_gateways }}"/> gateways are required.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">96</context>
+        </context-group>
+      </trans-unit><trans-unit id="6a3ac2b4137d723fd9878cd357c2012ff6c07973" datatype="html">
+        <source>Add portal</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">106</context>
+        </context-group>
+      </trans-unit><trans-unit id="e3484cae8b118c576ca2815bf9c9406c2eb2cae3" datatype="html">
+        <source>This image has modified settings.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">146</context>
+        </context-group>
+      </trans-unit><trans-unit id="107c84e820909b44fe258673938a68ced1bbff72" datatype="html">
+        <source>At least 1 image is required.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">151</context>
+        </context-group>
+      </trans-unit><trans-unit id="808038f912fdc7f0e03f82d4afd3bf9178527fc8" datatype="html">
+        <source>Add image</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">161</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">371</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">506</context>
+        </context-group>
+      </trans-unit><trans-unit id="f494bd31f095f6dcc656ce87ec2dcf07a2e9b30c" datatype="html">
+        <source>Initiators</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">174</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">437</context>
+        </context-group>
+      </trans-unit><trans-unit id="e98239d8a6be1100119ff4b5630c822b82786740" datatype="html">
+        <source>Initiator</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">181</context>
+        </context-group>
+      </trans-unit><trans-unit id="f2c5059d8cda15d8d03e2cce30f2d139623d9a91" datatype="html">
+        <source>Client IQN</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">194</context>
+        </context-group>
+      </trans-unit><trans-unit id="107d5aabce23d900f0a80e6ddc1c10e29aa0bed8" datatype="html">
+        <source>Initiator IQN needs to be unique.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">205</context>
+        </context-group>
+      </trans-unit><trans-unit id="e08a77594f3d89311cdf6da5090044270909c194" datatype="html">
+        <source>User</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">223</context>
+        </context-group>
+      </trans-unit><trans-unit id="bbf0b34a3fcc80800fcb44b9e1e86931a530dfe3" datatype="html">
+        <source>Usernames must have a length of 8 to 64 characters and
+                        can only contain letters, &apos;.&apos;, &apos;@&apos;, &apos;-&apos;, &apos;_&apos; or &apos;:&apos;.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">235</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">295</context>
+        </context-group>
+      </trans-unit><trans-unit id="c32ef07f8803a223a83ed17024b38e8d82292407" datatype="html">
+        <source>Password</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">245</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/auth/user-form/user-form.component.html</context>
+          <context context-type="linenumber">42</context>
+        </context-group>
+      </trans-unit><trans-unit id="4b2dd8635fba00476da25977e0884969821e62da" datatype="html">
+        <source>Passwords must have a length of 12 to 16 characters
+                        and can only contain letters, &apos;@&apos;, &apos;-&apos; or &apos;_&apos;.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">270</context>
+        </context-group>
+      </trans-unit><trans-unit id="ff40391de7a1944ea95091e4045cc34c4979b736" datatype="html">
+        <source>Mutual User</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">281</context>
+        </context-group>
+      </trans-unit><trans-unit id="0cf73dbebe99b737c4d288788182fc356e3c93d3" datatype="html">
+        <source>Mutual Password</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">305</context>
+        </context-group>
+      </trans-unit><trans-unit id="c58e136a292acf8ebccfa6d777fdff9f392b6ee2" datatype="html">
+        <source>Passwords must have a length of 12 to 16 characters and
+                        can only contain letters, &apos;@&apos;, &apos;-&apos; or &apos;_&apos;.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">330</context>
+        </context-group>
+      </trans-unit><trans-unit id="5d1878d5fc761cbe9614bfd87047a740c82a6951" datatype="html">
+        <source>Initiator belongs to a group. Images will be configure in the group.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">361</context>
+        </context-group>
+      </trans-unit><trans-unit id="c0de67b9d97fafbf200f9451e8388ee8128a56ac" datatype="html">
+        <source>No items added.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">384</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">521</context>
+        </context-group>
+      </trans-unit><trans-unit id="d565e47726158e428ecdc952fc9233b9b7d7f049" datatype="html">
+        <source>Add initiator</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">389</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">465</context>
+        </context-group>
+      </trans-unit><trans-unit id="c22ba03540aa3217da059f45e7eab138b51a96e2" datatype="html">
+        <source>Groups</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">403</context>
+        </context-group>
+      </trans-unit><trans-unit id="4c90059afafb7e160384d9f512797c95bb95c6dc" datatype="html">
+        <source>Group</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">410</context>
+        </context-group>
+      </trans-unit><trans-unit id="cff1428d10d59d14e45edec3c735a27b5482db59" datatype="html">
+        <source>Name</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">422</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
+          <context context-type="linenumber">36</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/cluster/configuration/configuration-form/configuration-form.component.html</context>
+          <context context-type="linenumber">18</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html</context>
+          <context context-type="linenumber">21</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
+          <context context-type="linenumber">26</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html</context>
+          <context context-type="linenumber">42</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/auth/role-form/role-form.component.html</context>
+          <context context-type="linenumber">19</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/cluster/configuration/configuration-details/configuration-details.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html</context>
+          <context context-type="linenumber">23</context>
+        </context-group>
+      </trans-unit><trans-unit id="3084948274cff4f56d0f431af47240e9cf02fcc7" datatype="html">
+        <source>Add group</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
+          <context context-type="linenumber">526</context>
+        </context-group>
+      </trans-unit><trans-unit id="54839ebc827b73c9dc4e1df731c6d36a85036af7" datatype="html">
+        <source>Are you sure that you want to <x id="INTERPOLATION" equiv-text="{{ actionDescription | lowercase }}"/> the selected <x id="INTERPOLATION_1" equiv-text="{{ itemDescription }}"/>?</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html</context>
+          <context context-type="linenumber">15</context>
+        </context-group>
+      </trans-unit><trans-unit id="2be8b7f04f0104d3fad90d079d8202b74f758b9a" datatype="html">
+        <source>Yes, I am sure.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html</context>
+          <context context-type="linenumber">25</context>
+        </context-group>
+      </trans-unit><trans-unit id="53a583cd5f15059cc958b7d547f72cc78f68e123" datatype="html">
+        <source>Please consult the <x id="START_LINK" ctype="x-a" equiv-text="&lt;a&gt;"/>documentation<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a&gt;"/>
+    on how to configure and enable the iSCSI Targets management functionality.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-list/iscsi-target-list.component.html</context>
+          <context context-type="linenumber">6</context>
+        </context-group>
+      </trans-unit><trans-unit id="3b301d0044f62c92af0da53d7aaca52a436a547d" datatype="html">
+        <source>Available information:</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-list/iscsi-target-list.component.html</context>
+          <context context-type="linenumber">12</context>
+        </context-group>
+      </trans-unit><trans-unit id="332227f088a4877b3c11f5fb3ae8bc812c470fae" datatype="html">
+        <source>iSCSI Targets not available</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-list/iscsi-target-list.component.html</context>
+          <context context-type="linenumber">4</context>
+        </context-group>
+      </trans-unit><trans-unit id="beb1391a148bd032914f49a2665dcc414a5d56d0" datatype="html">
+        <source>{VAR_SELECT, select, editing {Edit} cloning {Clone} copying {Copy} other {Add} }</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
+          <context context-type="linenumber">10</context>
+        </context-group>
+      </trans-unit><trans-unit id="52dd89f49fc440660cbbb3665b88d80f5baa7437" datatype="html">
+        <source>{VAR_SELECT, select, cloning {Clone from} copying {Copy from} other {Parent} }</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
+          <context context-type="linenumber">20</context>
+        </context-group>
       </trans-unit><trans-unit id="94516fa213706c67ce5a5b5765681d7fb032033a" datatype="html">
         <source>Loading...</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/cluster/monitor/monitor.component.html</context>
           <context context-type="linenumber">54</context>
         </context-group>
-      </trans-unit><trans-unit id="d7b35c384aecd25a516200d6921836374613dfe7" datatype="html">
-        <source>Cancel</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/shared/components/confirmation-modal/confirmation-modal.component.html</context>
-          <context context-type="linenumber">21</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html</context>
-          <context context-type="linenumber">38</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.html</context>
-          <context context-type="linenumber">38</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html</context>
-          <context context-type="linenumber">91</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html</context>
-          <context context-type="linenumber">34</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.html</context>
-          <context context-type="linenumber">25</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html</context>
-          <context context-type="linenumber">44</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html</context>
-          <context context-type="linenumber">45</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html</context>
-          <context context-type="linenumber">44</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html</context>
-          <context context-type="linenumber">45</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html</context>
-          <context context-type="linenumber">110</context>
-        </context-group>
-      </trans-unit><trans-unit id="54839ebc827b73c9dc4e1df731c6d36a85036af7" datatype="html">
-        <source>Are you sure that you want to <x id="INTERPOLATION" equiv-text="{{ actionDescription | lowercase }}"/> the selected <x id="INTERPOLATION_1" equiv-text="{{ itemDescription }}"/>?</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html</context>
-          <context context-type="linenumber">15</context>
-        </context-group>
-      </trans-unit><trans-unit id="2be8b7f04f0104d3fad90d079d8202b74f758b9a" datatype="html">
-        <source>Yes, I am sure.</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html</context>
-          <context context-type="linenumber">25</context>
-        </context-group>
       </trans-unit><trans-unit id="5ef50ba2514414f799d4c8fc36067a251904ba81" datatype="html">
         <source>Cluster-wide OSD Flags</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/auth/user-form/user-form.component.html</context>
           <context context-type="linenumber">10</context>
         </context-group>
-      </trans-unit><trans-unit id="c32ef07f8803a223a83ed17024b38e8d82292407" datatype="html">
-        <source>Password</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/core/auth/user-form/user-form.component.html</context>
-          <context context-type="linenumber">42</context>
-        </context-group>
       </trans-unit><trans-unit id="7f3bdcce4b2e8c37cd7f0f6c92ef8cff34b039b8" datatype="html">
         <source>Confirm password</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html</context>
           <context context-type="linenumber">40</context>
         </context-group>
+      </trans-unit><trans-unit id="e95ae009d0bdb45fcc656e8b65248cf7396080d5" datatype="html">
+        <source>Overview</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-tabs/iscsi-tabs.component.html</context>
+          <context context-type="linenumber">2</context>
+        </context-group>
+      </trans-unit><trans-unit id="bbd2045d5c37e4bb39a3c0fb62ea1ddf70a12838" datatype="html">
+        <source>Targets</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-tabs/iscsi-tabs.component.html</context>
+          <context context-type="linenumber">7</context>
+        </context-group>
       </trans-unit><trans-unit id="0f6e8f6094b180eaf1f11bc0ffe383f1cdcd059e" datatype="html">
         <source>Only available for RBD images with <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>fast-diff<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> enabled</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-trash-list/rbd-trash-list.component.html</context>
           <context context-type="linenumber">47</context>
         </context-group>
+      </trans-unit><trans-unit id="a674ab267d1934bf395f87ca1503fd474296893f" datatype="html">
+        <source>iSCSI Topology</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/iscsi-target-details/iscsi-target-details.component.html</context>
+          <context context-type="linenumber">2</context>
+        </context-group>
       </trans-unit><trans-unit id="66db799d67958d4b0765181d072df62e2d1c16f5" datatype="html">
         <source>Issues</source>
         <context-group purpose="location">
           <context context-type="linenumber">36</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="bd5a3b1c5a3c185c7bbb0e09a061d4cdc88ce5ad" datatype="html">
+        <source>Current</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="dd4ab758afd5fd5a6c6a25b2b30ff99d0c00e9ad" datatype="html">
+        <source>There are no portals available.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="83021e45778a4a230a14ca0c6d6ccdf05500ad93" datatype="html">
+        <source>There are no images available.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="86684eb21f8a768e1dbc659e3d5da6861db544a0" datatype="html">
+        <source>There are no images available. Please make sure you add an image to the target.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="fffda6e440078f57eba93944ce051c593cc6ed7f" datatype="html">
+        <source>There are no initiators available. Please make sure you add an initiator to the target.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="38baeb215c17af9d9e295e371a57f4a48ab4c191" datatype="html">
+        <source>Target</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="9a541ec1a4319fffc16ad3b3ab2c2b6d251a829d" datatype="html">
         <source>Hostname</source>
         <context-group purpose="location">
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="5c2114c69efd7a972c62db45f99b8b7550a5b673" datatype="html">
-        <source>There are no items.</source>
+      <trans-unit id="37391297bb077a6f84484930261b01a3ce38327b" datatype="html">
+        <source>No items selected.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/shared/components/select-badges/select-badges-messages.model.ts</context>
+          <context context-type="sourcefile">src/app/shared/components/select/select-messages.model.ts</context>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2c1e52ee832661b4a0f570877d24661736b16af1" datatype="html">
         <source>Deselect item to select again</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/shared/components/select-badges/select-badges-messages.model.ts</context>
+          <context context-type="sourcefile">src/app/shared/components/select/select-messages.model.ts</context>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
       <trans-unit id="c8c9c6e5918659336824bbdda3501c66eaa79a4c" datatype="html">
         <source>Selection limit reached</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/shared/components/select-badges/select-badges-messages.model.ts</context>
+          <context context-type="sourcefile">src/app/shared/components/select/select-messages.model.ts</context>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
       <trans-unit id="02d184c288f567825a1fcbf83bcd3099a10853d5" datatype="html">
         <source>Filter tags</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/shared/components/select-badges/select-badges-messages.model.ts</context>
+          <context context-type="sourcefile">src/app/shared/components/select/select-messages.model.ts</context>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
       <trans-unit id="aa00748e49c269956837d6f3acdd8d218796a8d8" datatype="html">
         <source>Add badge</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/shared/components/select-badges/select-badges-messages.model.ts</context>
+          <context context-type="sourcefile">src/app/shared/components/select/select-messages.model.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4078a92d8121abdce7d8f346a88914923ec835fc" datatype="html">
+        <source>There are no items available.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/components/select/select-messages.model.ts</context>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>