From 6ffaf6264e29862951484e0718b7230f7aa58aca Mon Sep 17 00:00:00 2001 From: Tiago Melo Date: Tue, 15 Jan 2019 15:34:54 +0000 Subject: [PATCH] mgr/dashboard: Add iSCSI Form UI Signed-off-by: Tiago Melo --- .../frontend/src/app/app-routing.module.ts | 4 +- .../src/app/ceph/block/block.module.ts | 14 +- .../iscsi-target-form.component.html | 549 ++++++++++++++++ .../iscsi-target-form.component.scss | 3 + .../iscsi-target-form.component.spec.ts | 333 ++++++++++ .../iscsi-target-form.component.ts | 463 ++++++++++++++ ...target-image-settings-modal.component.html | 44 ++ ...target-image-settings-modal.component.scss | 0 ...get-image-settings-modal.component.spec.ts | 56 ++ ...i-target-image-settings-modal.component.ts | 49 ++ ...i-target-iqn-settings-modal.component.html | 62 ++ ...i-target-iqn-settings-modal.component.scss | 0 ...arget-iqn-settings-modal.component.spec.ts | 52 ++ ...csi-target-iqn-settings-modal.component.ts | 50 ++ .../iscsi-target-list.component.html | 8 + .../iscsi-target-list.component.spec.ts | 288 ++++++++- .../iscsi-target-list.component.ts | 63 +- .../frontend/src/locale/messages.xlf | 587 +++++++++++++++--- 18 files changed, 2513 insertions(+), 112 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 3523d0b132c..87664ea740e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -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' } } ] } ] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index 651b1d5c1ab..d5bbbb9cc03 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -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 index 00000000000..00b7b78eecc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.html @@ -0,0 +1,549 @@ +
+
+
+
+

Create target

+
+ +
+ +
+ +
+
+ + + + +
+ + This field is required. + + IQN has wrong pattern. + + + An IQN has the following notation 'iqn.$year-$month.$reversedAddress:$definedName' +
+ For example: iqn.2016-06.org.dashboard:storage:disk.sn-a8675309 +
+ More information +
+ + This target has modified advanced settings. +
+
+
+ + +
+ +
+ + +
+ + + + +
+
+ + At least {{ minimum_gateways }} gateways are required. + +
+
+ + + Add portal + +
+
+ +
+
+
+ + +
+ +
+ +
+ + + + + + +
+ This image has modified settings. +
+ + At least 1 image is required. + +
+
+ + + Add image + +
+
+ +
+
+
+ + +
+ +
+
+
+ Initiator: {{ initiator.getValue('client_iqn') }} + +
+
+ +
+ +
+ + + Initiator IQN needs to be unique. + + This field is required. + + IQN has wrong pattern. +
+
+ + + +
+ +
+ + This field is required. + + Usernames must have a length of 8 to 64 characters and + can only contain letters, '.', '@', '-', '_' or ':'. +
+
+ + +
+ +
+
+ + + + + + +
+ This field is required. + + Passwords must have a length of 12 to 16 characters + and can only contain letters, '@', '-' or '_'. +
+
+ + + +
+ +
+ + + This field is required. + + Usernames must have a length of 8 to 64 characters and + can only contain letters, '.', '@', '-', '_' or ':'. +
+
+ + +
+ +
+
+ + + + + + +
+ This field is required. + + Passwords must have a length of 12 to 16 characters and + can only contain letters, '@', '-' or '_'. +
+
+
+ + +
+ +
+ +
+ + + + +
+
+ + Initiator belongs to a group. Images will be configure in the group. + +
+
+ + + Add image + +
+
+
+
+
+
+ +
+
+ No items added. + + +
+
+ +
+
+
+ + +
+ +
+
+
+ Group: {{ group.getValue('group_id') }} + +
+
+ +
+ +
+ +
+
+ + +
+ +
+ +
+ + + + +
+
+ +
+
+ + + Add initiator + +
+
+ +
+
+
+ + +
+ +
+ +
+ + + + +
+
+ +
+
+ + + Add image + +
+
+ +
+
+
+
+
+ +
+
+ No items added. + + +
+
+
+
+ +
+ +
+
+
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 index 00000000000..cebcc8877a2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.scss @@ -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 index 00000000000..faf35d0295c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.spec.ts @@ -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; + 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 index 00000000000..fd12c6e6058 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts @@ -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 index 00000000000..27b13dcb61c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html @@ -0,0 +1,44 @@ + + + Settings  + {{ image }} + + + +
+ + + +
+
+
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 index 00000000000..e69de29bb2d 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 index 00000000000..440736da826 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.spec.ts @@ -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; + + 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 index 00000000000..df0d3d07711 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.ts @@ -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 index 00000000000..23a56976f90 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html @@ -0,0 +1,62 @@ + + Advanced Settings + + +
+ + + +
+
+
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 index 00000000000..e69de29bb2d 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 index 00000000000..1862d6b9ebf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.spec.ts @@ -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; + + 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 index 00000000000..db0cdf33ea7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.ts @@ -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; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html index 97f35fa7ae9..33edc9660ac 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html @@ -23,6 +23,14 @@ forceIdentifier="true" selectionType="single" (updateSelection)="updateSelection($event)"> +
+ + +
+ { let component: IscsiTargetListComponent; let fixture: ComponentFixture; + 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([]); + }); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts index 21b1cb70571..91fe9265182 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts @@ -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) + }) + } + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf b/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf index 416e730353f..666cc701a58 100644 --- a/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf +++ b/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf @@ -104,9 +104,21 @@ app/core/navigation/navigation/navigation.component.html 128 + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 120 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 341 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 479 + app/ceph/block/iscsi/iscsi.component.html - 7 + 9 app/ceph/block/mirroring/overview/overview.component.html @@ -148,7 +160,7 @@ app/ceph/block/iscsi/iscsi.component.html - 1 + 3 app/ceph/block/mirroring/overview/overview.component.html @@ -198,6 +210,10 @@ app/shared/components/error-panel/error-panel.component.html 27 + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 544 + app/ceph/block/rbd-form/rbd-form.component.html 303 @@ -293,62 +309,134 @@ 118 X total - - {VAR_SELECT, select, editing {Edit} cloning {Clone} copying {Copy} other {Add} } + + Settings - app/ceph/block/rbd-form/rbd-form.component.html - 10 + app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html + 3 - - {VAR_SELECT, select, cloning {Clone from} copying {Copy from} other {Parent} } + + Changing these parameters from their default values is usually not necessary. - app/ceph/block/rbd-form/rbd-form.component.html - 20 + app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html + 15 - - Name - app/ceph/block/rbd-form/rbd-form.component.html - 36 + app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html + 13 + + Confirm - app/ceph/cluster/configuration/configuration-form/configuration-form.component.html - 18 + app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html + 35 - app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html + app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html + 53 + + + Cancel + + app/ceph/block/iscsi-target-image-settings-modal/iscsi-target-image-settings-modal.component.html + 39 + + + app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html + 57 + + + app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html + 38 + + + app/shared/components/confirmation-modal/confirmation-modal.component.html 21 - app/ceph/pool/pool-form/pool-form.component.html - 26 + app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.html + 38 - app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html - 42 + app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html + 91 - app/core/auth/role-form/role-form.component.html - 19 + app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html + 34 - app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html - 8 + app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.html + 25 - app/ceph/cluster/configuration/configuration-details/configuration-details.component.html - 8 + app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html + 44 - app/ceph/block/rbd-details/rbd-details.component.html - 13 + app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html + 45 - app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html - 23 + app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html + 44 + + + app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html + 45 + + + app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html + 110 + + + Advanced Settings + + app/ceph/block/iscsi-target-iqn-settings-modal/iscsi-target-iqn-settings-modal.component.html + 3 + + + Create target + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 11 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 539 + + + Target IQN + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 20 This field is required. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 43 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 209 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 231 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 266 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 291 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 326 + app/ceph/block/rbd-form/rbd-form.component.html 49 @@ -481,6 +569,298 @@ app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html 58 + + IQN has wrong pattern. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 47 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 213 + + + An IQN has the following notation 'iqn.$year-$month.$reversedAddress:$definedName' + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 51 + + + For example: iqn.2016-06.org.dashboard:storage:disk.sn-a8675309 + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 53 + + + More information + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 57 + + + This target has modified advanced settings. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 62 + + + Portals + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 72 + + + At least gateways are required. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 96 + + + Add portal + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 106 + + + This image has modified settings. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 146 + + + At least 1 image is required. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 151 + + + Add image + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 161 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 371 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 506 + + + Initiators + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 174 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 437 + + + Initiator + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 181 + + + Client IQN + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 194 + + + Initiator IQN needs to be unique. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 205 + + + User + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 223 + + + Usernames must have a length of 8 to 64 characters and + can only contain letters, '.', '@', '-', '_' or ':'. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 235 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 295 + + + Password + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 245 + + + app/core/auth/user-form/user-form.component.html + 42 + + + Passwords must have a length of 12 to 16 characters + and can only contain letters, '@', '-' or '_'. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 270 + + + Mutual User + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 281 + + + Mutual Password + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 305 + + + Passwords must have a length of 12 to 16 characters and + can only contain letters, '@', '-' or '_'. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 330 + + + Initiator belongs to a group. Images will be configure in the group. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 361 + + + No items added. + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 384 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 521 + + + Add initiator + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 389 + + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 465 + + + Groups + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 403 + + + Group + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 410 + + + Name + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 422 + + + app/ceph/block/rbd-form/rbd-form.component.html + 36 + + + app/ceph/cluster/configuration/configuration-form/configuration-form.component.html + 18 + + + app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html + 21 + + + app/ceph/pool/pool-form/pool-form.component.html + 26 + + + app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html + 42 + + + app/core/auth/role-form/role-form.component.html + 19 + + + app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html + 8 + + + app/ceph/cluster/configuration/configuration-details/configuration-details.component.html + 8 + + + app/ceph/block/rbd-details/rbd-details.component.html + 13 + + + app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html + 23 + + + Add group + + app/ceph/block/iscsi-target-form/iscsi-target-form.component.html + 526 + + + Are you sure that you want to the selected ? + + app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html + 15 + + + Yes, I am sure. + + app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html + 25 + + + Please consult the documentation + on how to configure and enable the iSCSI Targets management functionality. + + app/ceph/block/iscsi-target-list/iscsi-target-list.component.html + 6 + + + Available information: + + app/ceph/block/iscsi-target-list/iscsi-target-list.component.html + 12 + + + iSCSI Targets not available + + app/ceph/block/iscsi-target-list/iscsi-target-list.component.html + 4 + + + {VAR_SELECT, select, editing {Edit} cloning {Clone} copying {Copy} other {Add} } + + app/ceph/block/rbd-form/rbd-form.component.html + 10 + + + {VAR_SELECT, select, cloning {Clone from} copying {Copy from} other {Parent} } + + app/ceph/block/rbd-form/rbd-form.component.html + 20 + Loading... @@ -875,64 +1255,6 @@ app/ceph/cluster/monitor/monitor.component.html 54 - - Cancel - - app/shared/components/confirmation-modal/confirmation-modal.component.html - 21 - - - app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html - 38 - - - app/ceph/cluster/osd/osd-flags-modal/osd-flags-modal.component.html - 38 - - - app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html - 91 - - - app/ceph/cluster/osd/osd-reweight-modal/osd-reweight-modal.component.html - 34 - - - app/ceph/cluster/osd/osd-scrub-modal/osd-scrub-modal.component.html - 25 - - - app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html - 44 - - - app/ceph/block/rbd-trash-purge-modal/rbd-trash-purge-modal.component.html - 45 - - - app/ceph/block/rbd-trash-restore-modal/rbd-trash-restore-modal.component.html - 44 - - - app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html - 45 - - - app/ceph/block/mirroring/pool-edit-peer-modal/pool-edit-peer-modal.component.html - 110 - - - Are you sure that you want to the selected ? - - app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html - 15 - - - Yes, I am sure. - - app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html - 25 - Cluster-wide OSD Flags @@ -2236,12 +2558,6 @@ app/core/auth/user-form/user-form.component.html 10 - - Password - - app/core/auth/user-form/user-form.component.html - 42 - Confirm password @@ -2808,6 +3124,18 @@ app/ceph/block/rbd-trash-move-modal/rbd-trash-move-modal.component.html 40 + + Overview + + app/ceph/block/iscsi-tabs/iscsi-tabs.component.html + 2 + + + Targets + + app/ceph/block/iscsi-tabs/iscsi-tabs.component.html + 7 + Only available for RBD images with fast-diff enabled @@ -3022,6 +3350,12 @@ app/ceph/block/rbd-trash-list/rbd-trash-list.component.html 47 + + iSCSI Topology + + app/ceph/block/iscsi-target-details/iscsi-target-details.component.html + 2 + Issues @@ -3180,6 +3514,48 @@ 36 + + Current + + src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.ts + 1 + + + + There are no portals available. + + src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts + 1 + + + + There are no images available. + + src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts + 1 + + + + There are no images available. Please make sure you add an image to the target. + + src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts + 1 + + + + There are no initiators available. Please make sure you add an initiator to the target. + + src/app/ceph/block/iscsi-target-form/iscsi-target-form.component.ts + 1 + + + + Target + + src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts + 1 + + Hostname @@ -4972,38 +5348,45 @@ 1 - - There are no items. + + No items selected. - src/app/shared/components/select-badges/select-badges-messages.model.ts + src/app/shared/components/select/select-messages.model.ts 1 Deselect item to select again - src/app/shared/components/select-badges/select-badges-messages.model.ts + src/app/shared/components/select/select-messages.model.ts 1 Selection limit reached - src/app/shared/components/select-badges/select-badges-messages.model.ts + src/app/shared/components/select/select-messages.model.ts 1 Filter tags - src/app/shared/components/select-badges/select-badges-messages.model.ts + src/app/shared/components/select/select-messages.model.ts 1 Add badge - src/app/shared/components/select-badges/select-badges-messages.model.ts + src/app/shared/components/select/select-messages.model.ts + 1 + + + + There are no items available. + + src/app/shared/components/select/select-messages.model.ts 1 -- 2.39.5