]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add step two of subsystem create form 67128/head
authorAfreen Misbah <afreen@ibm.com>
Sun, 1 Feb 2026 23:47:23 +0000 (05:17 +0530)
committerAfreen Misbah <afreen@ibm.com>
Tue, 10 Feb 2026 09:06:14 +0000 (14:36 +0530)
- add steps to add initiators
- can add by input field
- added right influencer (right panel) in tearsheet component
- added unit tests
- includes api updates

Fixes https://tracker.ceph.com/issues/74096

Signed-off-by: Afreen Misbah <afreen@ibm.com>
18 files changed:
src/pybind/mgr/dashboard/controllers/nvmeof.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/tearsheet-step.ts
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss

index 8e28d19c8c9b8c37ccb79475ca021eb53a2ad987..f6f022d1d14a9f550c82ddd2095d7ad7ee9b87f0 100644 (file)
@@ -1434,13 +1434,37 @@ else:
         @empty_response
         @handle_nvmeof_error
         @CreatePermission
-        def add(self, subsystem_nqn: str, gw_group: str, host_nqn: str = ""):
+        def add(self, subsystem_nqn: str, host_nqn: str, dhchap_key: Optional[str] = None,
+                psk: Optional[str] = None, gw_group: Optional[str] = None,
+                server_address: Optional[str] = None
+                ):
             response = None
-            all_host_nqns = host_nqn.split(',')
 
-            for nqn in all_host_nqns:
-                response = NVMeoFClient(gw_group=gw_group).stub.add_host(
-                    NVMeoFClient.pb2.add_host_req(subsystem_nqn=subsystem_nqn, host_nqn=nqn)
+            if host_nqn != '*':
+                all_host_nqns = host_nqn.split(',')
+                for nqn in all_host_nqns:
+                    response = NVMeoFClient(
+                        gw_group=gw_group,
+                        server_address=server_address
+                    ).stub.add_host(
+                        NVMeoFClient.pb2.add_host_req(
+                            subsystem_nqn=subsystem_nqn,
+                            host_nqn=nqn,
+                            dhchap_key=dhchap_key,
+                            psk=psk)
+                    )
+                    if response.status != 0:
+                        return response
+            else:
+                response = NVMeoFClient(
+                    gw_group=gw_group,
+                    server_address=server_address
+                ).stub.add_host(
+                    NVMeoFClient.pb2.add_host_req(
+                        subsystem_nqn=subsystem_nqn,
+                        host_nqn=host_nqn,
+                        dhchap_key=dhchap_key,
+                        psk=psk)
                 )
                 if response.status != 0:
                     return response
index 77e8cafc2307fe9c96670c618b6c12e469ed8cf4..e56338a7ee6c637a24d01ce8c31edeca736a8f53 100644 (file)
@@ -50,6 +50,7 @@ import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form/nvmeof-i
 import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component';
 import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
 import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
+import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component';
 import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component';
 import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component';
 
@@ -70,6 +71,8 @@ import {
   TreeviewModule,
   TabsModule,
   TagModule,
+  LayoutModule,
+  ContainedListModule,
   LayerModule
 } from 'carbon-components-angular';
 
@@ -110,7 +113,9 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
     TabsModule,
     TagModule,
     GridModule,
-    LayerModule
+    LayerModule,
+    LayoutModule,
+    ContainedListModule
   ],
   declarations: [
     RbdListComponent,
@@ -151,6 +156,7 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
     NvmeofGatewayNodeComponent,
     NvmeofGroupFormComponent,
     NvmeofSubsystemsStepOneComponent,
+    NvmeofSubsystemsStepTwoComponent,
     NvmeofSubsystemsStepThreeComponent
   ],
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html
new file mode 100644 (file)
index 0000000..4bb3cdc
--- /dev/null
@@ -0,0 +1,146 @@
+
+<form [formGroup]="formGroup"
+      novalidate>
+  <div cdsGrid
+       [useCssGrid]="true"
+       [narrow]="true"
+       [fullWidth]="true">
+    <div cdsCol
+         [columnNumbers]="{lg: 10}"
+         class="cd-nvmeof-subsystem-step-two">
+      <div cdsRow
+           class="form-item cds-mb-0">
+        <h3 class="cds--type-heading-03"
+            i18n>Host access (Initiators)</h3>
+        <p
+          class="cds--type-label-02"
+          i18n>Select hosts that can initiate connections to this subsystem.</p>
+      </div>
+      <div cdsRow
+           class="form-item">
+        <cds-radio-group
+          formControlName="hostType"
+          orientation="vertical">
+          <cds-radio
+            [value]="HOST_TYPE.ALL"
+            i18n>Allow all hosts</cds-radio>
+          <span class="cds--form__helper-text cds-mb-3"
+                i18n>Any host can connect to this subsystem without verification.</span>
+          @if(formGroup.get('hostType').value === HOST_TYPE.ALL) {
+          <cd-alert-panel
+            title="Caution"
+            type="warning"
+            i18n
+            i18n-title>
+            Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access.
+          </cd-alert-panel>
+          }
+          <cds-radio [value]="HOST_TYPE.SPECIFIC">
+            <span i18n>Restrict to specific hosts</span>
+            <cds-tag
+              type="blue"
+              size="sm"
+              class="cd-nvmeof-subsystem-step-two-specific-hosts-tag"
+              i18n>
+              Recommended for secure environments
+            </cds-tag>
+          </cds-radio>
+          <span class="cds--form__helper-text"
+                i18n>Add the specific hosts permitted to connect.</span>
+        </cds-radio-group>
+      </div>
+      @if(formGroup.get('hostType').value === HOST_TYPE.SPECIFIC) {
+      <div cdsRow
+           class="form-item">
+        <h1
+          class="cds--type-heading-compact-01"
+          i18n>Add host manually</h1>
+        <label class="cds--label"
+               for="hostname">
+          <span i18n>Host name</span>
+        </label>
+        <div class="cd-nvmeof-subsystem-step-two-manual-hosts">
+          <cds-text-label
+            [invalid]="host.isInvalid"
+            [invalidText]="hostNameInvalidTemplate"
+            class="cd-nvmeof-subsystem-step-two-manual-hosts-input">
+          <input
+            cdsText
+            cdValidate
+            id="hostname"
+            #host="cdValidate"
+            formControlName="hostname"
+            [invalid]="host.isInvalid"
+            placeholder="Enter host NQN"
+            i18n-placeholder/>
+          </cds-text-label>
+          <button
+            cdsButton="tertiary"
+            [disabled]="host.isInvalid"
+            size="md"
+            class="cds-mt-3"
+            (click)="addHost()">
+            <span i18n>Add</span>
+            <cd-icon type="add"></cd-icon>
+          </button>
+        </div>
+      </div>
+      }
+    </div>
+  </div>
+</form>
+
+<ng-template #removeAllTemplate>
+  <button
+    i18n
+    cdsButton="ghost"
+    (click)="removeAll($event)">
+    Remove all
+  </button>
+</ng-template>
+
+<ng-template
+  #removeHostTemplate
+  let-host>
+  <cds-icon-button
+    type="button"
+    kind="ghost"
+    description="Remove host"
+    i18n-description
+    (click)="removeHost(host)">
+  <cd-icon type="destroy"></cd-icon>
+</cds-icon-button>
+</ng-template>
+
+<ng-template #rightInfluencer>
+  @if(addedHostsLength === 0) {
+  <h1
+    class="cds--type-heading-compact-01"
+    i18n>Added hosts ({{addedHostsLength}})</h1>
+  <p
+    i18n
+    class="cds--type-body-01 cd-nvmeof-subsystem-step-two-added-hosts-text">No hosts added yet. Add hosts manually or upload a CSV file.</p>
+  } @else {
+  <cds-contained-list
+    label="Added hosts ({{addedHostsLength}})"
+    [action]="removeAllTemplate"
+    class="cd-nvmeof-subsystem-step-two-influencer">
+    @for (host of formGroup.get('addedHosts')?.value ; track host) {
+    <cds-contained-list-item
+      [action]="removeHostTemplate"
+      [actionData]="host"
+      class="cd-nvmeof-subsystem-step-two-host-list-item-container">
+      <span
+        class="cds--text-truncate--end"
+        [title]="host">{{host}}</span>
+    </cds-contained-list-item>
+    }
+  </cds-contained-list>
+  }
+</ng-template>
+
+<ng-template #hostNameInvalidTemplate>
+@for (err of formGroup.get('hostname').errors | keyvalue; track err.key) {
+<span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+}
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.scss
new file mode 100644 (file)
index 0000000..28149d5
--- /dev/null
@@ -0,0 +1,31 @@
+.cd-nvmeof-subsystem-step-two {
+  &-manual-hosts {
+    display: flex;
+    align-items: flex-start;
+  }
+
+  &-manual-hosts-input {
+    flex: 0.5;
+    margin-right: var(--cds-spacing-05);
+  }
+
+  &-added-hosts-text {
+    color: var(--cds-text-secondary);
+  }
+
+  &-specific-hosts-tag {
+    max-inline-size: 17rem;
+    margin: 0 var(--cds-spacing-01);
+  }
+
+  &-influencer {
+    .cds--contained-list-item__content {
+      max-inline-size: 18rem !important;
+      padding-left: 0 !important;
+    }
+
+    .cds--contained-list__header {
+      padding: 0 !important;
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.spec.ts
new file mode 100644 (file)
index 0000000..f1f95c7
--- /dev/null
@@ -0,0 +1,117 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2.component';
+import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
+
+describe('NvmeofSubsystemsStepTwoComponent', () => {
+  let component: NvmeofSubsystemsStepTwoComponent;
+  let fixture: ComponentFixture<NvmeofSubsystemsStepTwoComponent>;
+  let form: CdFormGroup;
+  const mockGroupName = 'default';
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofSubsystemsStepTwoComponent],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        NgbTypeaheadModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        SharedModule,
+        InputModule,
+        GridModule,
+        RadioModule,
+        TagModule,
+        ToastrModule.forRoot()
+      ]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofSubsystemsStepTwoComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+    form = component.formGroup;
+    component.group = mockGroupName;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('form initialization', () => {
+    it('should initialize form with default values', () => {
+      expect(form).toBeTruthy();
+      expect(form.get('hostType')?.value).toBe(component.HOST_TYPE.SPECIFIC);
+      expect(form.get('hostname')?.value).toBe('');
+      expect(form.get('addedHosts')?.value).toEqual([]);
+    });
+  });
+
+  describe('showRightInfluencer', () => {
+    it('should return true when hostType is SPECIFIC', () => {
+      form.get('hostType')?.setValue(component.HOST_TYPE.SPECIFIC);
+      expect(component.showRightInfluencer()).toBeTruthy();
+    });
+
+    it('should return false when hostType is ALL', () => {
+      form.get('hostType')!.setValue(component.HOST_TYPE.ALL);
+
+      expect(form.get('hostType')!.value).toBe(component.HOST_TYPE.ALL);
+      expect(component.showRightInfluencer()).toBeFalsy();
+    });
+  });
+
+  describe('hostname validation', () => {
+    it('should not require hostname when hostType is ALL', () => {
+      form.get('hostType')?.setValue(component.HOST_TYPE.ALL);
+      form.get('hostname')?.setValue('');
+
+      expect(form.get('hostname')?.hasError('required')).toBeFalsy();
+    });
+  });
+
+  describe('custom NQN validator', () => {
+    it('should mark invalid NQN format', () => {
+      form.get('hostname')?.setValue('invalid-nqn');
+
+      expect(form.get('hostname')?.hasError('pattern')).toBeTruthy();
+    });
+
+    it('should accept valid NQN format', () => {
+      const validNqn = 'nqn.2023-01.com.example:host1';
+      form.get('hostname')?.setValue(validNqn);
+
+      expect(form.get('hostname')?.valid).toBeTruthy();
+    });
+  });
+
+  describe('addHost', () => {
+    it('should add hostname to addedHosts list', () => {
+      const hostname = 'nqn.2023-01.com.example:host1';
+      form.get('hostname')?.setValue(hostname);
+
+      component.addHost();
+
+      expect(form.get('addedHosts')?.value).toEqual([hostname]);
+      expect(component.addedHostsLength).toBe(1);
+    });
+
+    it('should not add empty hostname', () => {
+      form.get('hostname')?.setValue('');
+
+      component.addHost();
+
+      expect(form.get('addedHosts')?.value).toEqual([]);
+      expect(component.addedHostsLength).toBe(0);
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts
new file mode 100644 (file)
index 0000000..6e350dc
--- /dev/null
@@ -0,0 +1,106 @@
+import { Component, Input, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core';
+import { FormControl, UntypedFormControl } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
+
+@Component({
+  selector: 'cd-nvmeof-subsystem-step-two',
+  templateUrl: './nvmeof-subsystem-step-2.component.html',
+  styleUrls: ['./nvmeof-subsystem-step-2.component.scss'],
+  standalone: false,
+  encapsulation: ViewEncapsulation.None
+})
+export class NvmeofSubsystemsStepTwoComponent implements OnInit, TearsheetStep {
+  @Input() group!: string;
+  @ViewChild('rightInfluencer', { static: true })
+  rightInfluencer?: TemplateRef<any>;
+  formGroup: CdFormGroup;
+  action: string;
+  pageURL: string;
+  INVALID_TEXTS = {
+    pattern: $localize`Expected NQN format: "nqn.$year-$month.$reverseDomainName:$utf8-string" or "nqn.2014-08.org.nvmexpress:uuid:$UUID-string"`,
+    customRequired: $localize`This field is required`,
+    duplicate: $localize`Duplicate entry detected. Enter a unique value.`
+  };
+  HOST_TYPE = HOST_TYPE;
+  addedHostsLength: number = 0;
+  NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/;
+  NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+  ALLOW_ALL_HOST = '*';
+
+  constructor(public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal) {}
+
+  ngOnInit() {
+    this.createForm();
+  }
+
+  isValidNQN = CdValidators.custom(
+    'pattern',
+    (input: string) => !!input && !(this.NQN_REGEX.test(input) || this.NQN_REGEX_UUID.test(input))
+  );
+
+  isDuplicate = CdValidators.custom(
+    'duplicate',
+    (input: string) => !!input && this.formGroup?.get('addedHosts')?.value.includes(input)
+  );
+
+  isRequired = CdValidators.custom(
+    'customRequired',
+    (input: string) =>
+      !input &&
+      this.addedHostsLength === 0 &&
+      this.formGroup?.get('hostType')?.value === this.HOST_TYPE.SPECIFIC
+  );
+
+  showRightInfluencer(): boolean {
+    return this.formGroup.get('hostType')?.value === this.HOST_TYPE.SPECIFIC;
+  }
+
+  createForm() {
+    this.formGroup = new CdFormGroup({
+      hostType: new UntypedFormControl(this.HOST_TYPE.SPECIFIC),
+      hostname: new FormControl<string>('', {
+        validators: [this.isValidNQN, this.isDuplicate, this.isRequired]
+      }),
+      addedHosts: new FormControl<string[]>([])
+    });
+  }
+
+  addHost() {
+    const hostnameCtrl = this.formGroup.get('hostname');
+    hostnameCtrl.markAsTouched();
+    hostnameCtrl.updateValueAndValidity();
+    if (hostnameCtrl.value && hostnameCtrl.valid) {
+      const addedHosts = this.formGroup.get('addedHosts').value;
+      const newHostList = [...addedHosts, hostnameCtrl.value];
+      this.addedHostsLength = newHostList.length;
+      this.formGroup.patchValue({
+        addedHosts: newHostList,
+        hostname: ''
+      });
+    }
+  }
+
+  removeHost(removedHost: string) {
+    const currentAddedHosts = this.formGroup.get('addedHosts').value;
+    const newHostList = currentAddedHosts.filter((currentHost) => currentHost !== removedHost);
+    this.addedHostsLength = newHostList.length;
+    this.formGroup.patchValue({
+      addedHosts: newHostList
+    });
+    this.formGroup.get('hostname').updateValueAndValidity();
+  }
+
+  removeAll() {
+    this.addedHostsLength = 0;
+    this.formGroup.patchValue({
+      addedHosts: []
+    });
+    this.formGroup.get('hostname').updateValueAndValidity();
+  }
+}
index 59b322d6862492234b1624cf87be417f265d6b47..40c5fc997a7e358a20e0488cb2863dc25792c1db 100644 (file)
@@ -11,8 +11,9 @@
      [group]="group"></cd-nvmeof-subsystem-step-one>
   </cd-tearsheet-step>
   <cd-tearsheet-step>
-    <ng-template
-     #tearsheetStep></ng-template>
+    <cd-nvmeof-subsystem-step-two
+      #tearsheetStep
+      [group]="group"></cd-nvmeof-subsystem-step-two>
   </cd-tearsheet-step>
   <cd-tearsheet-step>
     <cd-nvmeof-subsystem-step-three
index b82df5cfdee4d669dd4e1236591ef60a22b48d8d..4c7e3d6c3f0f91bbaa80e772b0eec839826389fe 100644 (file)
@@ -16,6 +16,9 @@ import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
 import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
 import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
+import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component';
+import { of } from 'rxjs';
 
 describe('NvmeofSubsystemsFormComponent', () => {
   let component: NvmeofSubsystemsFormComponent;
@@ -26,7 +29,9 @@ describe('NvmeofSubsystemsFormComponent', () => {
   const mockPayload: SubsystemPayload = {
     nqn: '',
     gw_group: mockGroupName,
-    subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
+    subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=',
+    addedHosts: [],
+    hostType: HOST_TYPE.ALL
   };
 
   beforeEach(async () => {
@@ -35,7 +40,8 @@ describe('NvmeofSubsystemsFormComponent', () => {
       declarations: [
         NvmeofSubsystemsFormComponent,
         NvmeofSubsystemsStepOneComponent,
-        NvmeofSubsystemsStepThreeComponent
+        NvmeofSubsystemsStepThreeComponent,
+        NvmeofSubsystemsStepTwoComponent
       ],
       providers: [NgbActiveModal],
       imports: [
@@ -66,7 +72,8 @@ describe('NvmeofSubsystemsFormComponent', () => {
   describe('should test form', () => {
     beforeEach(() => {
       nvmeofService = TestBed.inject(NvmeofService);
-      spyOn(nvmeofService, 'createSubsystem').and.stub();
+      spyOn(nvmeofService, 'createSubsystem').and.returnValue(of({}));
+      spyOn(nvmeofService, 'addInitiators').and.returnValue(of({}));
     });
 
     it('should be creating request correctly', () => {
@@ -80,5 +87,23 @@ describe('NvmeofSubsystemsFormComponent', () => {
         dhchap_key: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
       });
     });
+
+    it('should add initiators with wildcard when hostType is ALL', () => {
+      const payload: SubsystemPayload = {
+        nqn: 'test-nqn',
+        gw_group: mockGroupName,
+        addedHosts: [],
+        hostType: HOST_TYPE.ALL,
+        subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
+      };
+
+      component.group = mockGroupName;
+      component.onSubmit(payload);
+
+      expect(nvmeofService.addInitiators).toHaveBeenCalledWith('test-nqn.default', {
+        host_nqn: '*',
+        gw_group: mockGroupName
+      });
+    });
   });
 });
index a85fb75861177a617f2415ac0642ae2bf70772ff..fb25ba1f815d2825517db21e07d787fd7a19ad9e 100644 (file)
@@ -1,22 +1,32 @@
-import { Component, DestroyRef, OnInit, ViewChild } from '@angular/core';
+import { Component, DestroyRef, OnInit, SecurityContext, ViewChild } from '@angular/core';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 
-import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { ActivatedRoute, Router } from '@angular/router';
 import { Step } from 'carbon-components-angular';
-import { FinishedTask } from '~/app/shared/models/finished-task';
-import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
+import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { from, Observable, of } from 'rxjs';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { catchError, concatMap, map, tap } from 'rxjs/operators';
+import { DomSanitizer } from '@angular/platform-browser';
 
 export type SubsystemPayload = {
   nqn: string;
   gw_group: string;
   subsystemDchapKey: string;
+  addedHosts: string[];
+  hostType: string;
 };
 
+type StepResult = { step: string; success: boolean; error?: string };
+
+const PAGE_URL = 'block/nvmeof/subsystems';
+
 @Component({
   selector: 'cd-nvmeof-subsystems-form',
   templateUrl: './nvmeof-subsystems-form.component.html',
@@ -39,12 +49,7 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
     },
     {
       label: $localize`Authentication`,
-      invalid: false
-    },
-    {
-      label: $localize`Advanced options`,
-      complete: false,
-      secondaryLabel: $localize`Advanced`
+      complete: false
     }
   ];
   title: string = $localize`Create Subsystem`;
@@ -59,8 +64,9 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
     private route: ActivatedRoute,
     private destroyRef: DestroyRef,
     private nvmeofService: NvmeofService,
-    private taskWrapperService: TaskWrapperService,
-    private router: Router
+    private notificationService: NotificationService,
+    private router: Router,
+    private sanitizer: DomSanitizer
   ) {}
 
   ngOnInit() {
@@ -68,32 +74,90 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
       this.group = params?.['group'];
     });
   }
-
   onSubmit(payload: SubsystemPayload) {
-    const component = this;
-    const pageURL = 'block/nvmeof/subsystems';
-    let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`;
     this.isSubmitLoading = true;
-    this.taskWrapperService
-      .wrapTaskAroundCall({
-        task: new FinishedTask(taskUrl, {
-          nqn: payload.nqn
-        }),
-        call: this.nvmeofService.createSubsystem({
-          nqn: payload.nqn,
-          gw_group: this.group,
-          dhchap_key: payload.subsystemDchapKey,
-          enable_ha: true
-        })
+    const stepResults: StepResult[] = [];
+    const initiatorRequest: InitiatorRequest = {
+      host_nqn: payload.hostType === HOST_TYPE.ALL ? '*' : payload.addedHosts.join(','),
+      gw_group: this.group
+    };
+
+    this.nvmeofService
+      .createSubsystem({
+        nqn: payload.nqn,
+        gw_group: this.group,
+        enable_ha: true,
+        dhchap_key: payload.subsystemDchapKey
       })
       .subscribe({
-        error() {
-          component.isSubmitLoading = false;
+        next: () => {
+          stepResults.push({ step: this.steps[0].label, success: true });
+          this.runSequentialSteps(
+            [
+              {
+                step: this.steps[1].label,
+                call: () =>
+                  this.nvmeofService.addInitiators(`${payload.nqn}.${this.group}`, initiatorRequest)
+              }
+            ],
+            stepResults
+          ).subscribe({
+            complete: () => this.showFinalNotification(stepResults)
+          });
         },
-        complete: () => {
-          component.isSubmitLoading = false;
-          this.router.navigate([pageURL, { outlets: { modal: null } }]);
+        error: (err) => {
+          err.preventDefault();
+          const errorMsg = err?.error?.detail || $localize`Subsystem creation failed`;
+          this.notificationService.show(
+            NotificationType.error,
+            $localize`Subsystem creation failed`,
+            errorMsg
+          );
+          this.isSubmitLoading = false;
+          this.router.navigate([PAGE_URL, { outlets: { modal: null } }]);
         }
       });
   }
+
+  private runSequentialSteps(
+    steps: { step: string; call: () => Observable<any> }[],
+    stepResults: StepResult[]
+  ): Observable<void> {
+    return from(steps).pipe(
+      concatMap((step) =>
+        step.call().pipe(
+          tap(() => stepResults.push({ step: step.step, success: true })),
+          catchError((err) => {
+            err.preventDefault();
+            const errorMsg = err?.error?.detail || '';
+            stepResults.push({ step: step.step, success: false, error: errorMsg });
+            return of(null);
+          })
+        )
+      ),
+      map(() => void 0)
+    );
+  }
+
+  private showFinalNotification(stepResults: StepResult[]) {
+    this.isSubmitLoading = false;
+
+    const messageLines = stepResults.map((stepResult) =>
+      stepResult.success
+        ? $localize`<div>${stepResult.step} step created successfully</div><br/>`
+        : $localize`<div>${stepResult.step} step failed: <code>${stepResult.error}</code></div><br/>`
+    );
+
+    const rawHtml = messageLines.join('<br/>');
+    const sanitizedHtml = this.sanitizer.sanitize(SecurityContext.HTML, rawHtml) ?? '';
+
+    const hasFailure = stepResults.some((r) => !r.success);
+    const type = hasFailure ? NotificationType.error : NotificationType.success;
+    const title = hasFailure
+      ? $localize`Subsystem created (with errors)`
+      : $localize`Subsystem created`;
+
+    this.notificationService.show(type, title, sanitizedHtml);
+    this.router.navigate([PAGE_URL, { outlets: { modal: null } }]);
+  }
 }
index 238f8ef5caac8c58050eef5bd8106361aeafd98f..0607b8dfe2716dc52fe5559c0e78ef8457c9573f 100644 (file)
@@ -106,7 +106,7 @@ import ErrorFilledIcon from '@carbon/icons/es/error--filled/16';
 import InformationFilledIcon from '@carbon/icons/es/information--filled/16';
 import WarningFilledIcon from '@carbon/icons/es/warning--filled/16';
 import NotificationFilledIcon from '@carbon/icons/es/notification--filled/16';
-import CloseIcon from '@carbon/icons/es/close/16';
+import { Close16 } from '@carbon/icons';
 import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component';
 import { ProductiveCardComponent } from './productive-card/productive-card.component';
 
@@ -268,7 +268,7 @@ export class ComponentsModule {
       InformationFilledIcon,
       WarningFilledIcon,
       NotificationFilledIcon,
-      CloseIcon
+      Close16
     ]);
   }
 }
index 946040a30569d82e4e5a1e91fc23e926503ec270..2ddcc4abcd1a1d2def1f129efe475ae4e6f58d92 100644 (file)
@@ -13,4 +13,14 @@ export class TearsheetStepComponent {
 
   @ContentChild('tearsheetStep')
   stepComponent!: TearsheetStep;
+
+  get rightInfluencer(): TemplateRef<any> | null {
+    return this.stepComponent?.rightInfluencer ?? null;
+  }
+
+  get showRightInfluencer(): boolean {
+    return this.stepComponent?.showRightInfluencer
+      ? this.stepComponent.showRightInfluencer()
+      : false;
+  }
 }
index 19bff6c52f2702ee1f62af82d171596f982c4dfd..4b49eda01373a62c837591d94877bdaa5c939509 100644 (file)
@@ -25,7 +25,7 @@
       <!-- Tearsheet Influencer-->
       <div cdsCol
            [columnNumbers]="{'lg': 3, 'md': 3, 'sm': 3}"
-           class="tearsheet-influencer">
+           class="tearsheet-left-influencer">
         <cds-progress-indicator
           orientation="vertical"
           [steps]="steps"
   </header>
   <section cdsGrid
            class="tearsheet-body"
+           [condensed]="true"
            [useCssGrid]="true"
            [fullWidth]="true">
-    <!-- Tearsheet Influencer-->
+    <!-- Tearsheet Left Influencer-->
     <div cdsCol
-         [columnNumbers]="{'lg': 3, 'md': 3, 'sm': 3}"
-         class="tearsheet-influencer">
+         [columnNumbers]="{lg: 3, md: 3, sm: 3}"
+         class="tearsheet-left-influencer">
       <cds-progress-indicator
         orientation="vertical"
         [steps]="steps"
       </cds-progress-indicator>
     </div>
     <div cdsCol
-         [columnNumbers]="{'lg': 13, 'md': 13, 'sm': 13}"
+         [columnNumbers]="{lg: 13, md: 13, sm: 13}"
          class="tearsheet-main">
-      <!-- Tearsheet Content Area -->
+      @if (showRightInfluencer) {
+      <!-- Tearsheet content with right influencer  -->
+      <div
+        cdsGrid
+        [condensed]="true"
+        [useCssGrid]="true"
+        [fullWidth]="true">
+        <div
+          cdsCol
+          class="tearsheet-content"
+          [columnNumbers]="{ lg: 10 }">
+          <ng-container *ngTemplateOutlet="activeStepTemplate"></ng-container>
+        </div>
+        <aside
+          cdsCol
+          [columnNumbers]="{ lg: 3 }"
+          class="tearsheet-right-influencer">
+          <ng-container *ngTemplateOutlet="rightInfluencerTemplate"></ng-container>
+        </aside>
+      </div>
+      }
+      @else {
+      <!-- Tearsheet content without right influencer -->
       <div class="tearsheet-content">
-      <ng-container
-        *ngTemplateOutlet="activeStepTemplate">
-      </ng-container>
+        <ng-container *ngTemplateOutlet="activeStepTemplate"></ng-container>
       </div>
+      }
       <!-- Tearsheet Footer -->
       <cds-modal-footer class="tearsheet-footer">
         <button cdsButton="ghost"
-                class="tearsheet-footer-cancel"
                 (click)="closeTearsheet()"
                 size="xl"
                 i18n>Cancel</button>
             [overlay]="false"
             size="sm">
           </cds-loading>
-           {{submitButtonLoadingLabel}}...
+            {{submitButtonLoadingLabel}}...
           }
           @else {
             {{submitButtonLabel}}
index 405e440cefd5f6dce5575af7c25fb969f68ea823..bbcd84b1701ab60cfea215dac06a35df93005332 100644 (file)
@@ -36,7 +36,7 @@
 // HEADER
 .tearsheet-header {
   fill: var(--cds-icon-primary);
-  background-color: var(--cds-layer-01);
+  background-color: var(--cds-background);
   padding: var(--cds-spacing-06) var(--cds-spacing-07);
   border-block-end: 1px solid var(--cds-border-subtle-01);
 
   padding: 0;
   margin: 0;
   height: 100%;
+}
+
+.tearsheet-left-influencer {
+  background-color: var(--cds-background);
+  padding: var(--cds-spacing-06) var(--cds-spacing-07);
+  overflow-block: auto;
+  overflow-y: auto;
+  border-inline-end: 1px solid var(--cds-border-subtle-01);
+  margin: 0;
+}
 
-  .tearsheet-influencer {
-    background-color: var(--cds-layer-01);
-    padding: var(--cds-spacing-06) var(--cds-spacing-07);
-    overflow-block: auto;
-    overflow-y: auto;
-    border-inline-end: 1px solid var(--cds-border-subtle-01);
-    margin: 0;
+.tearsheet-right-influencer {
+  background-color: var(--cds-background);
+  padding: var(--cds-spacing-05) var(--cds-spacing-05);
+}
+
+.tearsheet-main {
+  margin: 0;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+  > div {
+    flex: 1;
   }
+}
 
-  .tearsheet-main {
-    margin: 0;
-    display: flex;
-    flex-direction: column;
-    height: 100%;
-
-    .tearsheet-content {
-      background-color: var(--cds-background);
-      margin: 0;
-      padding: var(--cds-spacing-06) var(--cds-spacing-07);
-      flex: 1;
-      overflow-y: auto;
-
-      &--full {
-        padding-left: 0;
-      }
-    }
+.tearsheet-content {
+  background-color: var(--cds-layer-01);
+  margin: 0;
+  padding: var(--cds-spacing-06) var(--cds-spacing-07);
+  overflow-y: auto;
+
+  &--full {
+    padding-left: 0;
   }
 }
 
 //FOOTER
 .tearsheet-footer {
   border-top: 1px solid var(--cds-border-subtle);
-  background: var(--cds-background);
-
-  &-cancel {
-    margin-left: var(--cds-spacing-05);
-  }
+  background-color: var(--cds-layer-01);
 
   &-submit {
     display: flex;
index 36d3002eb29c30dfc0b761e9090474fa60b99d87..71419dd266406483555057ad2e3ec6b0fda52d7a 100644 (file)
@@ -8,7 +8,9 @@ import {
   QueryList,
   AfterViewInit,
   DestroyRef,
-  OnDestroy
+  OnDestroy,
+  ChangeDetectionStrategy,
+  TemplateRef
 } from '@angular/core';
 import { FormBuilder } from '@angular/forms';
 import { Step } from 'carbon-components-angular';
@@ -51,7 +53,8 @@ formgroup: CdFormGroup;
   selector: 'cd-tearsheet',
   standalone: false,
   templateUrl: './tearsheet.component.html',
-  styleUrls: ['./tearsheet.component.scss']
+  styleUrls: ['./tearsheet.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush
 })
 export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
   @Input() title!: string;
@@ -72,6 +75,14 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
     return this.stepContents?.toArray()[this.currentStep]?.template;
   }
 
+  get rightInfluencerTemplate(): TemplateRef<any> | null {
+    return this.stepContents?.toArray()[this.currentStep]?.rightInfluencer ?? null;
+  }
+
+  get showRightInfluencer(): boolean {
+    return this.stepContents?.toArray()[this.currentStep]?.showRightInfluencer;
+  }
+
   currentStep: number = 0;
   lastStep: number = null;
   isOpen: boolean = true;
@@ -124,6 +135,8 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
   }
 
   onNext() {
+    const formEl = document.querySelector('form');
+    formEl?.dispatchEvent(new Event('submit', { bubbles: true }));
     if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) {
       this.currentStep = this.currentStep + 1;
     }
index 27b1f333754b5eb4c463eafc7485d3ceade2ef53..67f8da625c2f2f23abf7d0cda0b45be8c402ad72 100644 (file)
@@ -126,5 +126,6 @@ export const ICON_TYPE = {
   notificationOff: 'notification--off',
   notificationNew: 'notification--new',
   success: 'success',
-  warning: 'warning'
+  warning: 'warning',
+  add: 'add'
 } as const;
index 7b3eb8d87defedafd24aa318dc6e1a19ecb1d0c7..976435853eda9197bf966894e4d9f90d936ee21f 100644 (file)
@@ -64,3 +64,8 @@ export enum AUTHENTICATION {
   Unidirectional = 'unidirectional',
   Bidirectional = 'bidirectional'
 }
+
+export const HOST_TYPE = {
+  ALL: 'all',
+  SPECIFIC: 'specific'
+};
index 556bb834d9898ca3cb423063816fc8b5b89f7aad..9688fc08ba5e9d18a6756d9eaa8aa0d08007b1f4 100644 (file)
@@ -1,5 +1,8 @@
+import { TemplateRef } from '@angular/core';
 import { FormGroup } from '@angular/forms';
 
 export interface TearsheetStep {
   formGroup: FormGroup;
+  rightInfluencer?: TemplateRef<any>;
+  showRightInfluencer?: () => boolean;
 }
index b4eb98629060b7ceb45283993d1cbdcbe0170522..fa9cf5cb746b3d2f3db77d1910581d64c834d20f 100644 (file)
@@ -1,5 +1,6 @@
 @use '@carbon/layout';
 
+// PADDINGS
 .cds-p-0 {
   padding: 0;
 }
@@ -8,20 +9,13 @@
   padding-top: layout.$spacing-03;
 }
 
-.cds-ml-3 {
-  margin-left: layout.$spacing-03;
-}
-
-.cds-ml-5 {
-  margin-left: layout.$spacing-05;
-}
-
-.cds-mr-3 {
-  margin-right: layout.$spacing-03;
+// MARGINS
+.cds-m-0 {
+  margin: 0;
 }
 
-.cds-mr-5 {
-  margin-right: layout.$spacing-05;
+.cds-mb-0 {
+  margin-bottom: 0;
 }
 
 .cds-mb-1 {
 .cds-mt-6 {
   margin-top: layout.$spacing-06;
 }
+
+.cds-ml-3 {
+  margin-left: layout.$spacing-03;
+}
+
+.cds-ml-5 {
+  margin-left: layout.$spacing-05;
+}
+
+.cds-mr-3 {
+  margin-right: layout.$spacing-03;
+}
+
+.cds-mr-5 {
+  margin-right: layout.$spacing-05;
+}