import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component';
import { NvmeofGroupFormComponent } from './nvmeof-group-form/nvmeof-group-form.component';
import { NvmeofEditHostKeyModalComponent } from './nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component';
+import { NvmeofSubsystemsStepFourComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component';
import {
ButtonModule,
NvmeSubsystemViewComponent,
NvmeofEditHostKeyModalComponent,
NvmeofSubsystemOverviewComponent,
- NvmeofSubsystemPerformanceComponent
+ NvmeofSubsystemPerformanceComponent,
+ NvmeofSubsystemsStepFourComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
spyOn(nvmeofService, 'createNamespace').and.returnValue(
of(new HttpResponse({ body: MOCK_NS_RESPONSE }))
);
- spyOn(nvmeofService, 'addNamespaceInitiators').and.returnValue(of({}));
+
spyOn(nvmeofService, 'getInitiators').and.returnValue(
of([{ nqn: 'host1' }, { nqn: 'host2' }])
);
formHelper = new FormHelper(form);
formHelper.setValue('pool', 'rbd');
});
- it('should create 5 namespaces correctly', () => {
- formHelper.setValue('pool', 'rbd');
- formHelper.setValue('image_size', new FormatterService().toBytes('1GiB'));
- formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
- component.onSubmit();
- expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(5);
- expect(nvmeofService.createNamespace).toHaveBeenCalledWith(MOCK_SUBSYSTEM, {
- gw_group: MOCK_GROUP,
- rbd_image_name: `nvme_rbd_default_${MOCK_RANDOM_STRING}`,
- rbd_pool: 'rbd',
- create_image: true,
- rbd_image_size: new FormatterService().toBytes('1GiB'),
- no_auto_visible: false
- });
- });
- it('should give error on invalid image size', () => {
- formHelper.setValue('image_size', -56);
- component.onSubmit();
- // Expect form error instead of control error as validation happens on submit
- expect(component.nsForm.hasError('cdSubmitButton')).toBeTruthy();
- });
- it('should give error on 0 image size', () => {
- formHelper.setValue('image_size', 0);
- component.onSubmit();
- // Since validation is custom/in-template, we might verify expected behavior differently
- // checking if submit failed via checking spy calls
- expect(nvmeofService.createNamespace).not.toHaveBeenCalled();
- expect(component.nsForm.hasError('cdSubmitButton')).toBeTruthy();
- });
-
- it('should require initiators when host access is specific', () => {
- formHelper.setValue('host_access', 'specific');
- formHelper.expectError('initiators', 'required');
- formHelper.setValue('initiators', ['host1']);
- formHelper.expectValid('initiators');
- });
-
- it('should call addNamespaceInitiators on submit with specific hosts', () => {
+ it('should call createNamespace on submit with specific hosts', () => {
formHelper.setValue('pool', 'rbd');
formHelper.setValue('image_size', new FormatterService().toBytes('1GiB'));
formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
formHelper.setValue('initiators', ['host1']);
component.onSubmit();
expect(nvmeofService.createNamespace).toHaveBeenCalled();
- // Wait for async operations if needed, or check if mocking is correct
- expect(nvmeofService.addNamespaceInitiators).toHaveBeenCalledTimes(5); // 5 namespaces created by default
- expect(nvmeofService.addNamespaceInitiators).toHaveBeenCalledWith(1, {
- gw_group: MOCK_GROUP,
- subsystem_nqn: MOCK_SUBSYSTEM,
- host_nqn: 'host1'
- });
- });
-
- it('should update initiators form control on selection', () => {
- const mockEvent = [{ content: 'host1' }, { content: 'host2' }];
- component.onInitiatorSelection(mockEvent);
- expect(component.nsForm.get('initiators').value).toEqual(['host1', 'host2']);
- expect(component.nsForm.get('initiators').dirty).toBe(true);
});
});
});
--- /dev/null
+<div cdsGrid
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true">
+ <div cdsCol
+ [columnNumbers]="{sm: 4, md: 8, lg: 12}">
+ <div cdsRow
+ class="form-heading">
+ <h3 class="cds--type-heading-03"
+ i18n>Review summary</h3>
+ </div>
+ </div>
+
+ <!-- Subsystem details -->
+ <div cdsCol
+ [columnNumbers]="{sm: 4, md: 8, lg: 12}"
+ class="cds-mt-5">
+ <h4 class="cds--type-heading-compact-01"
+ i18n>Subsystem details</h4>
+ </div>
+
+ <div cdsCol
+ [columnNumbers]="{sm: 2, md: 4, lg: 6}"
+ class="cds-mt-5">
+ <p class="cds--type-label-01"
+ i18n>Subsystem NQN</p>
+ <p class="cds--type-label-02 cds-mt-2">{{ nqn }}</p>
+ </div>
+ <div cdsCol
+ [columnNumbers]="{sm: 2, md: 4, lg: 6}"
+ class="cds-mt-5">
+ <p class="cds--type-label-01"
+ i18n>Gateway group</p>
+ <p class="cds--type-label-02 cds-mt-2">{{ group }}</p>
+ </div>
+
+ <div cdsCol
+ [columnNumbers]="{sm: 4, md: 8, lg: 12}"
+ class="cds-mt-5">
+ <p class="cds--type-label-01"
+ i18n>Listeners</p>
+ @if (listenerCount > 0) {
+ <p class="cds--type-label-02 cds-mt-2"
+ i18n>{{ listenerCount }} listener(s) added</p>
+ } @else {
+ <p class="cds--type-label-02 cds-mt-2"
+ i18n>None selected</p>
+ }
+ </div>
+
+ <!-- Host access control (Initiators) -->
+ <div cdsCol
+ [columnNumbers]="{sm: 4, md: 8, lg: 12}"
+ class="cds-mt-7">
+ <h4 class="cds--type-heading-compact-01"
+ i18n>Host access control (Initiators)</h4>
+ </div>
+
+ <div cdsCol
+ [columnNumbers]="{sm: 2, md: 4, lg: 6}"
+ class="cds-mt-5">
+ <p class="cds--type-label-01"
+ i18n>Host access</p>
+ <p class="cds--type-label-02 cds-mt-2">{{ hostAccessLabel }}</p>
+ </div>
+ @if (hostType === HOST_TYPE.SPECIFIC) {
+ <div cdsCol
+ [columnNumbers]="{sm: 2, md: 4, lg: 6}"
+ class="cds-mt-5">
+ <p class="cds--type-label-01"
+ i18n>Specific hosts</p>
+ <p class="cds--type-label-02 cds-mt-2"
+ i18n>{{ hostCount }} hosts added.</p>
+ </div>
+ }
+
+ <!-- Authentication details -->
+ <div cdsCol
+ [columnNumbers]="{sm: 4, md: 8, lg: 12}"
+ class="cds-mt-7">
+ <h4 class="cds--type-heading-compact-01"
+ i18n>Authentication details</h4>
+ </div>
+
+ <div cdsCol
+ [columnNumbers]="{sm: 2, md: 4, lg: 6}"
+ class="cds-mt-5">
+ <p class="cds--type-label-01"
+ i18n>Authentication type</p>
+ <p class="cds--type-label-02 cds-mt-2">{{ authTypeLabel }}</p>
+ </div>
+ @if (authType === AUTHENTICATION.Bidirectional) {
+ <div cdsCol
+ [columnNumbers]="{sm: 2, md: 4, lg: 6}"
+ class="cds-mt-5">
+ <p class="cds--type-label-01"
+ i18n>Subsystem DH-HMAC-CHAP key</p>
+ @if (hasSubsystemKey) {
+ <p class="cds--type-label-02 cds-mt-2">••••••••••••</p>
+ } @else {
+ <p class="cds--type-label-02 cds-mt-2"
+ i18n>Not set</p>
+ }
+ </div>
+ }
+ <div cdsCol
+ [columnNumbers]="{sm: 4, md: 8, lg: 12}"
+ class="cds-mt-5">
+ <p class="cds--type-label-01"
+ i18n>Host key</p>
+ @if (hostDchapKeyCount > 0) {
+ <p class="cds--type-label-02 cds-mt-2"
+ i18n>{{ hostDchapKeyCount }} keys added</p>
+ } @else {
+ <p class="cds--type-label-02 cds-mt-2"
+ i18n>No keys added</p>
+ }
+ </div>
+</div>
--- /dev/null
+// Styles handled via Carbon layout utilities (cdsGrid, cdsCol, cdsStack, cds-mt-*, cds-mb-*)
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofSubsystemsStepFourComponent } from './nvmeof-subsystem-step-4.component';
+import { GridModule } from 'carbon-components-angular';
+import { AUTHENTICATION, HOST_TYPE } from '~/app/shared/models/nvmeof';
+
+describe('NvmeofSubsystemsStepFourComponent', () => {
+ let component: NvmeofSubsystemsStepFourComponent;
+ let fixture: ComponentFixture<NvmeofSubsystemsStepFourComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofSubsystemsStepFourComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ GridModule,
+ ToastrModule.forRoot()
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofSubsystemsStepFourComponent);
+ component = fixture.componentInstance;
+ component.group = 'default';
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should have an empty formGroup', () => {
+ expect(component.formGroup).toBeTruthy();
+ });
+
+ it('should return correct host access label for ALL hosts', () => {
+ component.hostType = HOST_TYPE.ALL;
+ expect(component.hostAccessLabel).toContain('All');
+ });
+
+ it('should return correct host access label for SPECIFIC hosts', () => {
+ component.hostType = HOST_TYPE.SPECIFIC;
+ expect(component.hostAccessLabel).toContain('Restricted');
+ });
+
+ it('should return correct auth type label', () => {
+ component.authType = AUTHENTICATION.Bidirectional;
+ expect(component.authTypeLabel).toContain('Bidirectional');
+
+ component.authType = AUTHENTICATION.Unidirectional;
+ expect(component.authTypeLabel).toContain('Unidirectional');
+ });
+
+ it('should return correct listener count', () => {
+ component.listeners = [{ content: 'host1', addr: '1.2.3.4' }];
+ expect(component.listenerCount).toBe(1);
+ });
+
+ it('should detect subsystem key presence', () => {
+ component.subsystemDchapKey = '';
+ expect(component.hasSubsystemKey).toBe(false);
+
+ component.subsystemDchapKey = 'somekey';
+ expect(component.hasSubsystemKey).toBe(true);
+ });
+});
--- /dev/null
+import { Component, Input, OnInit } from '@angular/core';
+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 { AUTHENTICATION, HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
+
+@Component({
+ selector: 'cd-nvmeof-subsystem-step-four',
+ templateUrl: './nvmeof-subsystem-step-4.component.html',
+ styleUrls: ['./nvmeof-subsystem-step-4.component.scss'],
+ standalone: false
+})
+export class NvmeofSubsystemsStepFourComponent implements OnInit, TearsheetStep {
+ @Input() group!: string;
+ @Input() nqn: string = '';
+ @Input() listeners: any[] = [];
+ @Input() hostType: string = HOST_TYPE.SPECIFIC;
+ @Input() addedHosts: string[] = [];
+ @Input() authType: string = AUTHENTICATION.Unidirectional;
+ @Input() subsystemDchapKey: string = '';
+ @Input() hostDchapKeyCount: number = 0;
+
+ formGroup: CdFormGroup;
+ HOST_TYPE = HOST_TYPE;
+ AUTHENTICATION = AUTHENTICATION;
+
+ constructor(public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal) {}
+
+ ngOnInit() {
+ this.formGroup = new CdFormGroup({});
+ }
+
+ get listenerCount(): number {
+ return this.listeners?.length || 0;
+ }
+
+ get hostAccessLabel(): string {
+ return this.hostType === HOST_TYPE.ALL ? $localize`All hosts` : $localize`Restricted`;
+ }
+
+ get hostCount(): number {
+ return this.addedHosts?.length || 0;
+ }
+
+ get authTypeLabel(): string {
+ return this.authType === AUTHENTICATION.Bidirectional
+ ? $localize`Bidirectional`
+ : $localize`Unidirectional`;
+ }
+
+ get hasSubsystemKey(): boolean {
+ return !!this.subsystemDchapKey;
+ }
+}
[description]="description"
[isSubmitLoading]="isSubmitLoading"
(submitRequested)="onSubmit($event)"
+ (stepChanged)="populateReviewData()"
>
<cd-tearsheet-step>
<cd-nvmeof-subsystem-step-one
#tearsheetStep
[group]="group"></cd-nvmeof-subsystem-step-three>
</cd-tearsheet-step>
+ <cd-tearsheet-step>
+ <cd-nvmeof-subsystem-step-four
+ #tearsheetStep
+ [group]="group"
+ [nqn]="reviewNqn"
+ [listeners]="reviewListeners"
+ [hostType]="reviewHostType"
+ [addedHosts]="reviewAddedHosts"
+ [authType]="reviewAuthType"
+ [subsystemDchapKey]="reviewSubsystemDchapKey"
+ [hostDchapKeyCount]="reviewHostDchapKeyCount"></cd-nvmeof-subsystem-step-four>
+ </cd-tearsheet-step>
</cd-tearsheet>
+
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 { NvmeofSubsystemsStepFourComponent } from './nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component';
import { of } from 'rxjs';
describe('NvmeofSubsystemsFormComponent', () => {
NvmeofSubsystemsFormComponent,
NvmeofSubsystemsStepOneComponent,
NvmeofSubsystemsStepThreeComponent,
- NvmeofSubsystemsStepTwoComponent
+ NvmeofSubsystemsStepTwoComponent,
+ NvmeofSubsystemsStepFourComponent
],
providers: [
NgbActiveModal,
import { Step } from 'carbon-components-angular';
import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service';
import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
-import { HOST_TYPE, ListenerItem } from '~/app/shared/models/nvmeof';
+import { HOST_TYPE, ListenerItem, AUTHENTICATION } 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';
{
label: $localize`Authentication`,
complete: false
+ },
+ {
+ label: $localize`Review`,
+ complete: false
}
];
title: string = $localize`Create Subsystem`;
@ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent;
+ // Review step data
+ reviewNqn: string = '';
+ reviewListeners: any[] = [];
+ reviewHostType: string = HOST_TYPE.SPECIFIC;
+ reviewAddedHosts: string[] = [];
+ reviewAuthType: string = AUTHENTICATION.Unidirectional;
+ reviewSubsystemDchapKey: string = '';
+ reviewHostDchapKeyCount: number = 0;
+
constructor(
public actionLabels: ActionLabelsI18n,
public activeModal: NgbActiveModal,
this.group = params?.['group'];
});
}
+
+ populateReviewData() {
+ if (!this.tearsheet?.stepContents) return;
+ const steps = this.tearsheet.stepContents.toArray();
+
+ // Step 1: Subsystem details
+ const step1Form = steps[0]?.stepComponent?.formGroup;
+ if (step1Form) {
+ this.reviewNqn = step1Form.get('nqn')?.value || '';
+ this.reviewListeners = step1Form.get('listeners')?.value || [];
+ }
+
+ // Step 2: Host access control
+ const step2Form = steps[1]?.stepComponent?.formGroup;
+ if (step2Form) {
+ this.reviewHostType = step2Form.get('hostType')?.value || HOST_TYPE.SPECIFIC;
+ this.reviewAddedHosts = step2Form.get('addedHosts')?.value || [];
+ }
+
+ // Step 3: Authentication
+ const step3Form = steps[2]?.stepComponent?.formGroup;
+ if (step3Form) {
+ this.reviewAuthType = step3Form.get('authType')?.value || AUTHENTICATION.Unidirectional;
+ this.reviewSubsystemDchapKey = step3Form.get('subsystemDchapKey')?.value || '';
+ const hostKeys = step3Form.get('hostDchapKeyList')?.value || [];
+ this.reviewHostDchapKeyCount = hostKeys.filter((k: any) => k?.key).length;
+ }
+ }
+
onSubmit(payload: SubsystemPayload) {
this.isSubmitLoading = true;
this.lastCreatedNqn = payload.nqn;
import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs';
import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
-import { TableComponent } from '~/app/shared/datatable/table/table.component';
const BASE_URL = 'block/nvmeof/subsystems';
const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
@ViewChild('customTableItemTemplate', { static: true })
customTableItemTemplate: TemplateRef<any>;
- @ViewChild('table') table: TableComponent;
-
subsystems: (NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[] = [];
pendingNqn: string = null;
subsystemsColumns: any;
ngOnInit() {
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
- if (params?.['nqn']) this.pendingNqn = params['nqn'];
if (params?.['group']) this.onGroupSelection({ content: params?.['group'] });
});
this.setGatewayGroups();
@Output() submitRequested = new EventEmitter<void>();
@Output() closeRequested = new EventEmitter<void>();
+ @Output() stepChanged = new EventEmitter<number>();
@ContentChildren(TearsheetStepComponent)
stepContents!: QueryList<TearsheetStepComponent>;
onStepSelect(event: { step: Step; index: number }) {
this.currentStep = event.index;
+ this.stepChanged.emit(this.currentStep);
}
closeTearsheet() {
onPrevious() {
if (this.currentStep !== 0) {
this.currentStep = this.currentStep - 1;
+ this.stepChanged.emit(this.currentStep);
}
}
formEl?.dispatchEvent(new Event('submit', { bubbles: true }));
if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) {
this.currentStep = this.currentStep + 1;
+ this.stepChanged.emit(this.currentStep);
}
}