}
]
},
+ {
+ path: `namespaces/${URLVerbs.CREATE}`,
+ component: NvmeofNamespacesFormComponent,
+ data: { breadcrumbs: ActionLabels.CREATE + ' ' + $localize`Namespace` }
+ },
{
path: 'subsystems',
component: NvmeofSubsystemsComponent,
{
path: `${URLVerbs.CREATE}/:subsystem_nqn/namespace`,
component: NvmeofNamespacesFormComponent,
- outlet: 'modal'
+ data: { breadcrumbs: ActionLabels.CREATE + ' ' + $localize`Namespace` }
},
{
path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
component: NvmeofNamespacesFormComponent,
- outlet: 'modal'
+ data: { breadcrumbs: ActionLabels.EDIT + ' ' + $localize`Namespace` }
},
// initiators
{
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
import { NvmeofGatewayComponent } from './nvmeof-gateway.component';
import { RouterTestingModule } from '@angular/router/testing';
import { SharedModule } from '~/app/shared/shared.module';
import { ComboBoxModule, GridModule, TabsModule } from 'carbon-components-angular';
+import { of } from 'rxjs';
describe('NvmeofGatewayComponent', () => {
let component: NvmeofGatewayComponent;
GridModule,
TabsModule
],
- providers: []
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParams: of({})
+ }
+ }
+ ]
}).compileComponents();
fixture = TestBed.createComponent(NvmeofGatewayComponent);
import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { CdValidators } from '~/app/shared/forms/cd-validators';
-import { Icons } from '~/app/shared/enum/icons.enum';
import { Permission } from '~/app/shared/models/permissions';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-<cd-modal [pageURL]="pageURL"
- [modalRef]="activeModal">
- <span class="modal-title"
- i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
- <ng-container class="modal-content">
- <form name="nsForm"
- #formDir="ngForm"
- [formGroup]="nsForm"
- novalidate>
- <div class="modal-body">
- <!-- Block Pool -->
- <div class="form-group row">
- <label class="cd-col-form-label"
- for="pool">
- <span [ngClass]="{'required': !edit}"
- i18n>Pool</span>
- </label>
- <div class="cd-col-form-input">
- <input *ngIf="edit"
- class="form-control"
- type="text"
- id="pool-edit"
- formControlName="pool">
- <select *ngIf="!edit"
- id="pool-create"
- class="form-select"
- formControlName="pool">
- <option *ngIf="rbdPools === null"
- [ngValue]="null"
- i18n>Loading...</option>
- <option *ngIf="rbdPools && rbdPools.length === 0"
- [ngValue]="null"
- i18n>-- No block pools available --</option>
- <option *ngIf="rbdPools && rbdPools.length > 0"
- [ngValue]="null"
- i18n>-- Select a pool --</option>
- <option *ngFor="let pool of rbdPools"
- [value]="pool.pool_name">{{ pool.pool_name }}</option>
- </select>
- <cd-help-text i18n>
- An RBD application-enabled pool where the image will be created.
- </cd-help-text>
- <span class="invalid-feedback"
- *ngIf="nsForm.showError('pool', formDir, 'required')"
- i18n>This field is required.</span>
+<form
+ [formGroup]="nsForm"
+ novalidate>
+ <div cdsGrid
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true">
+
+ <div cdsCol
+ [columnNumbers]="{sm: 4, md: 8}">
+ <div cdsRow
+ class="form-heading form-item">
+ <h3>{{ action | titlecase }} {{ resource | titlecase }}</h3>
+ <cd-help-text [formAllFieldsRequired]="true">
+ <span i18n>
+ Namespaces define the storage volumes that subsystems present to hosts.
+ </span>
+ </cd-help-text>
+ </div>
+
+ <!-- Namespace Count (Create only) -->
+ @if (!edit) {
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <cds-number
+ formControlName="nsCount"
+ cdValidate
+ #nsCountRef="cdValidate"
+ label="Number of namespaces"
+ helperText="No. of namespaces to generate. Value must be between 1 and 5."
+ [min]="MIN_NAMESPACE_CREATE"
+ [max]="MAX_NAMESPACE_CREATE"
+ [invalid]="nsCountRef.isInvalid"
+ [invalidText]="nsCountError"
+ i18n-label
+ i18n-helperText>
+ </cds-number>
+ <ng-template #nsCountError>
+ <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('nsCount') }"></ng-container>
+ </ng-template>
+ </div>
+ </div>
+ }
+
+ <!-- Namespace Size (sent as block_size) -->
+ @if (!edit) {
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <cds-number
+ formControlName="namespace_size"
+ cdOptionalField="Namespace size (GiB)"
+ label="Namespace size (GiB)"
+ helperText="Specify the size to expose to hosts. Leave blank for full device/file."
+ placeholder="e.g. 100"
+ [min]="0"
+ [invalid]="nsForm.controls['namespace_size'].invalid && (nsForm.controls['namespace_size'].dirty || nsForm.controls['namespace_size'].touched)"
+ invalidText="Value must be greater than or equal to 0."
+ i18n-label
+ i18n-helperText
+ i18n-invalidText>
+ </cds-number>
+ </div>
+ </div>
+ }
+
+ <!-- Host Access (drives no_auto_visible in create request) -->
+ @if (!edit) {
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <legend
+ class="cds--type-label-01"
+ i18n>Host access (Initiators)</legend>
+ <div class="form-item">
+ <cds-radio-group
+ formControlName="host_access"
+ orientation="horizontal">
+ <cds-radio
+ value="all"
+ [checked]="true">
+ <div>
+ <span
+ class="cds--type-body-compact-01"
+ i18n>All hosts on the subsystem</span>
+ <span
+ class="cds--type-helper-text-01 cds-ml-1 d-block"
+ i18n>Allow all hosts associated with the selected subsystem to access the namespace.</span>
+ </div>
+ </cds-radio>
+ <cds-radio value="specific">
+ <div>
+ <span
+ class="cds--type-body-compact-01"
+ i18n>Select specific hosts</span>
+ <span
+ class="cds--type-helper-text-01 cds-ml-1 d-block"
+ i18n>Only the selected hosts will be able to access this namespace.</span>
+ </div>
+ </cds-radio>
+ </cds-radio-group>
</div>
</div>
- <!-- Namespace Count -->
- <div *ngIf="!edit"
- class="form-group row"
- id="namespace-count">
- <label class="cd-col-form-label"
- for="nsCount">
- <span [ngClass]="{'required': !edit}"
- i18n>Namespace Count</span>
- </label>
- <div class="cd-col-form-input">
- <cds-number
- formControlName="nsCount"
- helperText="The number of namespaces to create"
- i18n-helperText
- [min]="MIN_NAMESPACE_CREATE"
- [max]="MAX_NAMESPACE_CREATE"
- [invalid]="nsForm.showError('nsCount', formDir, 'max') || nsForm.showError('nsCount', formDir, 'min') || nsForm.showError('nsCount', formDir, 'required')"
- [invalidText]="nsForm.get('nsCount').hasError('required') ? requiredInvalidText: nsCountInvalidText"
- size="sm"></cds-number>
+ </div>
+ }
+
+ <!-- Host Selection (Visible only when 'specific' is selected) -->
+ @if (!edit && nsForm.getValue('host_access') === 'specific') {
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <div class="form-item">
+ <cds-combo-box
+ type="multi"
+ selectionFeedback="top-after-reopen"
+ label="Select hosts"
+ i18n-label
+ placeholder="Select one or more hosts"
+ [appendInline]="true"
+ [items]="initiatorCandidates"
+ (selected)="onInitiatorSelection($event)"
+ [invalid]="nsForm.controls['initiators'].invalid && (nsForm.controls['initiators'].dirty || nsForm.controls['initiators'].touched)"
+ invalidText="This field is required."
+ i18n-invalidText
+ i18n-placeholder>
+ <cds-dropdown-list></cds-dropdown-list>
+ </cds-combo-box>
</div>
</div>
- <!-- Image Size -->
- <div class="form-group row">
- <label class="cd-col-form-label"
- for="image_size">
- <span [ngClass]="{'required': edit}"
- i18n>Image Size</span>
- </label>
- <div class="cd-col-form-input">
- <div class="input-group">
- <input id="size"
- class="form-control"
- type="text"
- formControlName="image_size">
- <select id="unit"
- class="form-input form-select"
- formControlName="unit">
- <option *ngFor="let u of units"
- [value]="u"
- i18n>{{ u }}</option>
- </select>
- <span class="invalid-feedback"
- *ngIf="nsForm.showError('image_size', formDir, 'pattern')">
- <ng-container i18n>Enter a positive integer.</ng-container>
- </span>
- <span class="invalid-feedback"
- *ngIf="edit && nsForm.showError('image_size', formDir, 'required')">
- <ng-container i18n>This field is required</ng-container>
- </span>
- <span class="invalid-feedback"
- id="image-size-invalid"
- *ngIf="edit && invalidSizeError">
- <ng-container i18n>Enter a value above than previous. A block device image can be expanded but not reduced.</ng-container>
- </span>
- </div>
+ </div>
+ }
+
+ <!-- Subsystem -->
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ @if (edit) {
+ <cds-text-label
+ label="Subsystem"
+ i18n-label>
+ <input cdsText
+ readonly
+ [value]="nsForm.get('subsystem').value" />
+ </cds-text-label>
+ } @else {
+ <cds-select
+ formControlName="subsystem"
+ cdValidate
+ #subsystemRef="cdValidate"
+ label="Select subsystem"
+ [invalid]="subsystemRef.isInvalid"
+ invalidText="This field is required."
+ i18n-label
+ i18n-invalidText>
+ @if (subsystems === undefined) {
+ <option
+ [ngValue]="null"
+ disabled>Loading...</option>
+ }
+ @if (subsystems && subsystems.length === 0) {
+ <option
+ [ngValue]="null"
+ disabled>-- No subsystems available --</option>
+ }
+ @if (subsystems && subsystems.length > 0) {
+ <option
+ value=""
+ selected>Select a subsystem</option>
+ }
+ @for (subsystem of subsystems; track subsystem.nqn) {
+ <option
+ [value]="subsystem.nqn">{{ subsystem.nqn }}</option>
+ }
+ </cds-select>
+ }
+ </div>
+ </div>
+
+ <!-- Pool -->
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ @if (edit) {
+ <cds-text-label
+ label="RBD image pool"
+ i18n-label>
+ <input cdsText
+ readonly
+ [value]="nsForm.get('pool').value" />
+ </cds-text-label>
+ } @else {
+ <cds-select
+ formControlName="pool"
+ cdValidate
+ #poolRef="cdValidate"
+ label="RBD image pool"
+ [invalid]="poolRef.isInvalid"
+ invalidText="This field is required."
+ helperText="Pool where the backing Ceph block device resides."
+ i18n-label
+ i18n-invalidText
+ i18n-helperText>
+ @if (rbdPools === null) {
+ <option
+ [ngValue]="null"
+ disabled>Loading...</option>
+ }
+ @if (rbdPools && rbdPools.length === 0) {
+ <option
+ [ngValue]="null"
+ disabled>-- No block pools available --</option>
+ }
+ @if (rbdPools && rbdPools.length > 0) {
+ <option
+ value=""
+ selected>Select a RBD image pool</option>
+ }
+ @for (pool of rbdPools; track pool.pool_name) {
+ <option
+ [value]="pool.pool_name">{{ pool.pool_name }}</option>
+ }
+ </cds-select>
+ }
+ </div>
+ </div>
+
+ <!-- RBD image creation (drives create_image flag in request) -->
+ @if (!edit) {
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <legend
+ class="cds--type-label-01"
+ i18n>RBD image creation</legend>
+ <cds-radio-group
+ formControlName="rbd_image_creation"
+ orientation="vertical">
+ <cds-radio
+ value="gateway_provisioned"
+ i18n>Gateway-provisioned image</cds-radio>
+ <cds-radio
+ value="externally_managed"
+ [disabled]="nsForm.getValue('nsCount') > 1"
+ [title]="nsForm.getValue('nsCount') > 1 ? 'Unavailable during bulk creation. RBD images are created automatically.' : ''"
+ i18n>Externally managed image</cds-radio>
+ </cds-radio-group>
+ </div>
+ </div>
+ }
+
+ <!-- Image Name (Visible when 'gateway_provisioned' and nsCount > 1) -->
+ @if (!edit && nsForm.getValue('rbd_image_creation') === 'gateway_provisioned' && nsForm.getValue('nsCount') > 1) {
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <cd-alert-panel
+ type="info"
+ [dismissible]="false"
+ [showTitle]="false">
+ <strong i18n>For bulk namespace creation, RBD images are provisioned automatically.</strong>
+ </cd-alert-panel>
+
+ <div class="form-item"
+ cdOptionalField="Image name">
+ <label
+ class="cds--type-label-01 cds--label"
+ i18n>Image name</label>
+ <cds-text-label
+ helperText="Provide a name for the images. For bulk creation, this will be used as the base prefix with numeric suffixes (e.g., img-1, img-2). Leave blank to auto-generate."
+ [invalid]="rbdImageNameRef.isInvalid"
+ [invalidText]="rbdImageNameError"
+ i18n-helperText>
+ <input cdsText
+ placeholder="Enter a name"
+ formControlName="rbd_image_name"
+ cdValidate
+ #rbdImageNameRef="cdValidate"
+ [invalid]="rbdImageNameRef.isInvalid" />
+ </cds-text-label>
+ <ng-template #rbdImageNameError>
+ <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('rbd_image_name') }"></ng-container>
+ </ng-template>
</div>
</div>
</div>
- <div class="modal-footer">
- <div class="text-right">
- <cd-form-button-panel (submitActionEvent)="onSubmit()"
- [form]="nsForm"
- [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ }
+
+ <!-- Image Selection (Visible only when 'externally_managed' is selected) -->
+ @if (!edit && nsForm.getValue('rbd_image_creation') === 'externally_managed') {
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <cds-select
+ formControlName="rbd_image_name"
+ cdValidate
+ #rbdImageSelectRef="cdValidate"
+ label="RBD Image"
+ [invalid]="rbdImageSelectRef.isInvalid"
+ invalidText="This field is required."
+ helperText="Select an existing RBD image from the pool to expose as a namespace."
+ i18n-label
+ i18n-invalidText
+ i18n-helperText>
+ <option
+ [ngValue]="null"
+ disabled
+ selected>Select an image</option>
+ @for (img of rbdImages; track img.name) {
+ <option
+ [value]="img.name">{{ img.name }} ({{ img.size | dimlessBinary }})</option>
+ }
+ </cds-select>
</div>
</div>
- </form>
- </ng-container>
-</cd-modal>
+ }
+
+ <!-- Image Size -->
+ @if (!edit && nsForm.getValue('rbd_image_creation') !== 'externally_managed') {
+ <div cdsRow
+ class="form-item">
+ <div
+ cdsCol
+ [columnNumbers]="{md: 4}">
+ <cds-text-label
+ helperText="The size of the namespace image."
+ [invalid]="imageSizeRef.isInvalid"
+ [invalidText]="sizeError"
+ i18n-helperText>
+ Image Size
+ <input cdsText
+ type="text"
+ placeholder="e.g. 100 GiB"
+ id="image_size"
+ formControlName="image_size"
+ cdValidate
+ #imageSizeRef="cdValidate"
+ [invalid]="imageSizeRef.isInvalid"
+ defaultUnit="GiB"
+ [min]="0"
+ cdDimlessBinary>
+ </cds-text-label>
+ <ng-template #sizeError>
+ <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('image_size') }"></ng-container>
+ </ng-template>
+ </div>
+ </div>
+ }
+
+ <div cdsRow>
+ <cd-form-button-panel
+ (submitActionEvent)="onSubmit()"
+ [form]="nsForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | titlecase)"
+ wrappingClass="text-right form-button">
+ </cd-form-button-panel>
+ </div>
+
+ </div>
+ </div>
+</form>
+
+<ng-template #validationErrors
+ let-control="control">
+ @if (control.errors) {
+ @for (err of control.errors | keyvalue; track err.key) {
+ <span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+ }
+ }
+</ng-template>
import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { HttpResponse } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form.component';
import { FormHelper, Mocks } from '~/testing/unit-test-helper';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { of } from 'rxjs';
+import { of, Observable } from 'rxjs';
import { PoolService } from '~/app/shared/api/pool.service';
-import { NumberModule } from 'carbon-components-angular';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { NumberModule, RadioModule, ComboBoxModule, SelectModule } from 'carbon-components-angular';
import { ActivatedRoute, Router } from '@angular/router';
import { By } from '@angular/platform-browser';
import { ActivatedRouteStub } from '~/testing/activated-route-stub';
+import { NvmeofInitiatorCandidate } from '~/app/shared/models/nvmeof';
const MOCK_POOLS = [
Mocks.getPool('pool-1', 1, ['cephfs']),
}
}
+class MockTaskWrapperService {
+ wrapTaskAroundCall(args: { task: any; call: Observable<any> }) {
+ return args.call;
+ }
+}
+
const MOCK_NS_RESPONSE = {
nsid: 1,
uuid: '185d541f-76bf-45b5-b445-f71829346c38',
providers: [
NgbActiveModal,
{ provide: PoolService, useClass: MockPoolsService },
- { provide: ActivatedRoute, useValue: activatedRouteStub }
+ { provide: ActivatedRoute, useValue: activatedRouteStub },
+ { provide: TaskWrapperService, useClass: MockTaskWrapperService }
],
imports: [
HttpClientTestingModule,
RouterTestingModule,
SharedModule,
NumberModule,
+ RadioModule,
+ ComboBoxModule,
+ SelectModule,
ToastrModule.forRoot()
]
}).compileComponents();
beforeEach(() => {
router = TestBed.inject(Router);
nvmeofService = TestBed.inject(NvmeofService);
- spyOn(nvmeofService, 'createNamespace').and.stub();
+ 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' }])
+ );
spyOn(component, 'randomString').and.returnValue(MOCK_RANDOM_STRING);
Object.defineProperty(router, 'url', {
get: jasmine.createSpy('url').and.returnValue(MOCK_ROUTER.createUrl)
component.ngOnInit();
form = component.nsForm;
formHelper = new FormHelper(form);
- });
- it('should have set create fields correctly', () => {
- expect(component.rbdPools.length).toBe(2);
- fixture.detectChanges();
- const poolEl = fixture.debugElement.query(By.css('#pool-create')).nativeElement;
- expect(poolEl.value).toBe('rbd');
+ formHelper.setValue('pool', 'rbd');
});
it('should create 5 namespaces correctly', () => {
+ formHelper.setValue('pool', 'rbd');
+ formHelper.setValue('image_size', 1073741824);
+ formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
component.onSubmit();
expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(5);
expect(nvmeofService.createNamespace).toHaveBeenCalledWith(MOCK_SUBSYSTEM, {
rbd_image_name: `nvme_rbd_default_${MOCK_RANDOM_STRING}`,
rbd_pool: 'rbd',
create_image: true,
- rbd_image_size: 1073741824
+ rbd_image_size: 1073741824,
+ no_auto_visible: false
});
});
+
+ it('should create multiple namespaces with suffixed custom image names', () => {
+ formHelper.setValue('pool', 'rbd');
+ formHelper.setValue('image_size', 1073741824);
+ formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
+ formHelper.setValue('nsCount', 2);
+ formHelper.setValue('rbd_image_name', 'test-img');
+ component.onSubmit();
+ expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(2);
+ expect((nvmeofService.createNamespace as any).calls.argsFor(0)[1].rbd_image_name).toBe(
+ 'test-img-1'
+ );
+ expect((nvmeofService.createNamespace as any).calls.argsFor(1)[1].rbd_image_name).toBe(
+ 'test-img-2'
+ );
+ });
it('should give error on invalid image size', () => {
formHelper.setValue('image_size', -56);
component.onSubmit();
- formHelper.expectError('image_size', 'pattern');
+ // 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();
- formHelper.expectError('image_size', 'min');
+ // 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', () => {
+ formHelper.setValue('pool', 'rbd');
+ formHelper.setValue('image_size', 1073741824);
+ formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
+ formHelper.setValue('host_access', 'specific');
+ 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: NvmeofInitiatorCandidate[] = [
+ { content: 'host1', selected: true },
+ { content: 'host2', selected: true }
+ ];
+ component.onInitiatorSelection(mockEvent);
+ expect(component.nsForm.get('initiators').value).toEqual(['host1', 'host2']);
+ expect(component.nsForm.get('initiators').dirty).toBe(true);
});
});
describe('should test edit form', () => {
router = TestBed.inject(Router);
nvmeofService = TestBed.inject(NvmeofService);
spyOn(nvmeofService, 'getNamespace').and.returnValue(of(MOCK_NS_RESPONSE));
- spyOn(nvmeofService, 'updateNamespace').and.stub();
+ spyOn(nvmeofService, 'updateNamespace').and.returnValue(
+ of(new HttpResponse({ status: 200 }))
+ );
Object.defineProperty(router, 'url', {
get: jasmine.createSpy('url').and.returnValue(MOCK_ROUTER.editUrl)
});
fixture.detectChanges();
});
+
it('should have set edit fields correctly', () => {
expect(nvmeofService.getNamespace).toHaveBeenCalledTimes(1);
- const poolEl = fixture.debugElement.query(By.css('#pool-edit')).nativeElement;
- expect(poolEl.disabled).toBeTruthy();
- expect(poolEl.value).toBe(MOCK_NS_RESPONSE['rbd_pool_name']);
- const sizeEl = fixture.debugElement.query(By.css('#size')).nativeElement;
- expect(sizeEl.value).toBe('1');
- const unitEl = fixture.debugElement.query(By.css('#unit')).nativeElement;
- expect(unitEl.value).toBe('GiB');
+ expect(component.nsForm.get('pool').disabled).toBeTruthy();
+ expect(component.nsForm.get('pool').value).toBe(MOCK_NS_RESPONSE['rbd_pool_name']);
+ // Size formatted by pipe
+ expect(component.nsForm.get('image_size').value).toBe('1 GiB');
});
- it('should not show namesapce count ', () => {
- const nsCountEl = fixture.debugElement.query(By.css('#namespace-count'));
+
+ it('should not show namespace count', () => {
+ const nsCountEl = fixture.debugElement.query(By.css('cds-number[formControlName="nsCount"]'));
expect(nsCountEl).toBeFalsy();
});
+
it('should give error with no change in image size', () => {
- component.onSubmit();
- expect(component.invalidSizeError).toBe(true);
- fixture.detectChanges();
- const imageSizeInvalidEL = fixture.debugElement.query(By.css('#image-size-invalid'));
- expect(imageSizeInvalidEL).toBeTruthy();
+ component.nsForm.get('image_size').updateValueAndValidity();
+ expect(component.nsForm.get('image_size').hasError('minSize')).toBe(true);
});
+
it('should give error when size less than previous (1 GB) provided', () => {
form = component.nsForm;
formHelper = new FormHelper(form);
- formHelper.setValue('unit', 'MiB');
- component.onSubmit();
- expect(component.invalidSizeError).toBe(true);
- fixture.detectChanges();
- const imageSizeInvalidEL = fixture.debugElement.query(By.css('#image-size-invalid'))
- .nativeElement;
- expect(imageSizeInvalidEL).toBeTruthy();
+ formHelper.setValue('image_size', '512 MiB'); // Less than 1 GiB
+ component.nsForm.get('image_size').updateValueAndValidity();
+ expect(component.nsForm.get('image_size').hasError('minSize')).toBe(true);
});
+
it('should have edited namespace successfully', () => {
component.ngOnInit();
form = component.nsForm;
formHelper = new FormHelper(form);
- formHelper.setValue('image_size', 2);
+ formHelper.setValue('image_size', '2 GiB');
component.onSubmit();
expect(nvmeofService.updateNamespace).toHaveBeenCalledTimes(1);
expect(nvmeofService.updateNamespace).toHaveBeenCalledWith(MOCK_SUBSYSTEM, MOCK_NSID, {
-import { Component, OnInit } from '@angular/core';
+import { Component, OnDestroy, OnInit } from '@angular/core';
import { UntypedFormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import {
NamespaceCreateRequest,
+ NamespaceInitiatorRequest,
NamespaceUpdateRequest,
NvmeofService
} from '~/app/shared/api/nvmeof.service';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { FinishedTask } from '~/app/shared/models/finished-task';
-import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
+import {
+ NvmeofSubsystem,
+ NvmeofSubsystemInitiator,
+ NvmeofSubsystemNamespace,
+ NvmeofNamespaceListResponse,
+ NvmeofInitiatorCandidate,
+ NsFormField,
+ RbdImageCreation,
+ HOST_TYPE
+} from '~/app/shared/models/nvmeof';
import { Permission } from '~/app/shared/models/permissions';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { Pool } from '../../pool/pool';
import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdPool, RbdImage } from '~/app/shared/api/rbd.model';
import { RbdService } from '~/app/shared/api/rbd.service';
import { FormatterService } from '~/app/shared/services/formatter.service';
-import { forkJoin, Observable } from 'rxjs';
+import { forkJoin, Observable, of, Subject } from 'rxjs';
+import { filter, switchMap, takeUntil, tap } from 'rxjs/operators';
import { CdValidators } from '~/app/shared/forms/cd-validators';
import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
import { HttpResponse } from '@angular/common/http';
styleUrls: ['./nvmeof-namespaces-form.component.scss'],
standalone: false
})
-export class NvmeofNamespacesFormComponent implements OnInit {
+export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
action: string;
permission: Permission;
poolPermission: Permission;
edit: boolean = false;
nsForm: CdFormGroup;
subsystemNQN: string;
- rbdPools: Array<Pool> = null;
- units: Array<string> = ['MiB', 'GiB', 'TiB'];
+ subsystems?: NvmeofSubsystem[];
+ rbdPools: Pool[] | null = null;
+ rbdImages: RbdImage[] = [];
+ initiatorCandidates: NvmeofInitiatorCandidate[] = [];
+
+ // Stores all RBD images fetched for the selected pool
+ private allRbdImages: RbdImage[] = [];
+ // Maps pool name to a Set of used image names for O(1) lookup
+ private usedRbdImages: Map<string, Set<string>> = new Map();
+ private lastSubsystemNqn: string;
+
nsid: string;
- currentBytes: number;
- invalidSizeError: boolean;
+ currentBytes: number = 0;
group: string;
MAX_NAMESPACE_CREATE: number = 5;
MIN_NAMESPACE_CREATE: number = 1;
- requiredInvalidText: string = $localize`This field is required`;
- nsCountInvalidText: string = $localize`The namespace count should be between 1 and 5`;
+ private destroy$ = new Subject<void>();
+ INVALID_TEXTS: Record<string, string> = {
+ required: $localize`This field is required.`,
+ min: $localize`The namespace count should be between 1 and 5.`,
+ max: $localize`The namespace count should be between 1 and 5.`,
+ minSize: $localize`Enter a value larger than previous. A block device image can be expanded but not reduced.`,
+ rbdImageName: $localize`Image name contains invalid characters.`
+ };
constructor(
public actionLabels: ActionLabelsI18n,
private rbdService: RbdService,
private router: Router,
private route: ActivatedRoute,
- public activeModal: NgbActiveModal,
public formatterService: FormatterService,
public dimlessBinaryPipe: DimlessBinaryPipe
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
this.poolPermission = this.authStorageService.getPermissions().pool;
this.resource = $localize`Namespace`;
- this.pageURL = 'block/nvmeof/subsystems';
+ this.pageURL = 'block/nvmeof/gateways';
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
}
init() {
this.route.queryParams.subscribe((params) => {
this.group = params?.['group'];
+ if (params?.['subsystem_nqn']) {
+ this.subsystemNQN = params?.['subsystem_nqn'];
+ }
});
+
this.createForm();
this.action = this.actionLabels.CREATE;
this.route.params.subscribe((params: { subsystem_nqn: string; nsid: string }) => {
this.nvmeofService
.getNamespace(this.subsystemNQN, this.nsid, this.group)
.subscribe((res: NvmeofSubsystemNamespace) => {
- const convertedSize = this.dimlessBinaryPipe.transform(res.rbd_image_size).split(' ');
this.currentBytes =
typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
- this.nsForm.get('pool').setValue(res.rbd_pool_name);
- this.nsForm.get('unit').setValue(convertedSize[1]);
- this.nsForm.get('image_size').setValue(convertedSize[0]);
- this.nsForm.get('image_size').addValidators(Validators.required);
- this.nsForm.get('pool').disable();
+ this.nsForm.get(NsFormField.POOL).setValue(res.rbd_pool_name);
+ this.nsForm
+ .get(NsFormField.IMAGE_SIZE)
+ .setValue(this.dimlessBinaryPipe.transform(res.rbd_image_size));
+ this.nsForm.get(NsFormField.IMAGE_SIZE).addValidators(Validators.required);
+ this.nsForm.get(NsFormField.POOL).disable();
+ this.nsForm.get(NsFormField.SUBSYSTEM).disable();
+ this.nsForm.get(NsFormField.SUBSYSTEM).setValue(this.subsystemNQN);
});
}
initForCreate() {
this.poolService.getList().subscribe((resp: Pool[]) => {
this.rbdPools = resp.filter(this.rbdService.isRBDPool);
- if (this.rbdPools?.length) {
- this.nsForm.get('pool').setValue(this.rbdPools[0].pool_name);
- }
});
+ this.route.queryParams
+ .pipe(
+ filter((params) => params?.['group']),
+ tap((params) => {
+ this.group = params['group'];
+ this.fetchUsedImages();
+ }),
+ switchMap(() => this.nvmeofService.listSubsystems(this.group))
+ )
+ .subscribe((subsystems: NvmeofSubsystem[]) => {
+ this.subsystems = subsystems;
+ if (this.subsystemNQN) {
+ const selectedSubsystem = this.subsystems.find((s) => s.nqn === this.subsystemNQN);
+ if (selectedSubsystem) {
+ this.nsForm.get(NsFormField.SUBSYSTEM).setValue(selectedSubsystem.nqn);
+ }
+ }
+ });
}
ngOnInit() {
} else {
this.initForCreate();
}
+ const subsystemControl = this.nsForm.get(NsFormField.SUBSYSTEM);
+ if (subsystemControl) {
+ subsystemControl.valueChanges.subscribe((nqn: string) => {
+ this.onSubsystemChange(nqn);
+ });
+ }
+ }
+
+ onPoolChange(): void {
+ const pool = this.nsForm.getValue(NsFormField.POOL);
+ if (!pool) return;
+
+ this.rbdService
+ .list({ pool_name: pool, offset: '0', limit: '-1' })
+ .subscribe((pools: RbdPool[]) => {
+ const selectedPool = pools.find((p) => p.pool_name === pool);
+ this.allRbdImages = selectedPool?.value ?? [];
+ this.filterImages();
+
+ const imageControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME);
+ const currentImage = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
+ if (currentImage && !this.rbdImages.some((img) => img.name === currentImage)) {
+ imageControl.setValue(null);
+ }
+ imageControl.markAsUntouched();
+ imageControl.markAsPristine();
+ });
+ }
+
+ fetchUsedImages(): void {
+ if (!this.group) return;
+
+ this.nvmeofService
+ .listNamespaces(this.group)
+ .subscribe((response: NvmeofNamespaceListResponse) => {
+ const namespaces: NvmeofSubsystemNamespace[] = Array.isArray(response)
+ ? response
+ : response?.namespaces ?? [];
+ this.usedRbdImages = namespaces.reduce((map, ns) => {
+ if (!map.has(ns.rbd_pool_name)) {
+ map.set(ns.rbd_pool_name, new Set<string>());
+ }
+ map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name);
+ return map;
+ }, new Map<string, Set<string>>());
+ this.filterImages();
+ });
+ }
+
+ onSubsystemChange(nqn: string): void {
+ if (!nqn || nqn === this.lastSubsystemNqn) return;
+ this.lastSubsystemNqn = nqn;
+ this.nvmeofService
+ .getInitiators(nqn, this.group)
+ .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
+ const initiators = Array.isArray(response) ? response : response?.hosts || [];
+ this.initiatorCandidates = initiators.map((initiator) => ({
+ content: initiator.nqn,
+ selected: false
+ }));
+ });
+ }
+
+ onInitiatorSelection(event: NvmeofInitiatorCandidate[]) {
+ // Carbon ComboBox (selected) emits the full array of selected items
+ const selectedInitiators = Array.isArray(event) ? event.map((e) => e.content) : [];
+ this.nsForm
+ .get(NsFormField.INITIATORS)
+ .setValue(selectedInitiators.length > 0 ? selectedInitiators : null);
+ this.nsForm.get(NsFormField.INITIATORS).markAsDirty();
+ this.nsForm.get(NsFormField.INITIATORS).markAsTouched();
+ }
+
+ private filterImages(): void {
+ const pool = this.nsForm.getValue(NsFormField.POOL);
+ if (!pool) {
+ this.rbdImages = [];
+ return;
+ }
+ const usedInPool = this.usedRbdImages.get(pool);
+ this.rbdImages = usedInPool
+ ? this.allRbdImages.filter((img) => !usedInPool.has(img.name))
+ : [...this.allRbdImages];
}
createForm() {
this.nsForm = new CdFormGroup({
- pool: new UntypedFormControl(null, {
+ [NsFormField.POOL]: new UntypedFormControl('', {
validators: [Validators.required]
}),
- image_size: new UntypedFormControl(1, [CdValidators.number(false), Validators.min(1)]),
- unit: new UntypedFormControl(this.units[1]),
- nsCount: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
+ [NsFormField.SUBSYSTEM]: new UntypedFormControl('', {
+ validators: [Validators.required]
+ }),
+ [NsFormField.IMAGE_SIZE]: new UntypedFormControl(null, {
+ validators: [
+ Validators.required,
+ CdValidators.custom('minSize', (value: any) => {
+ if (value !== null && value !== undefined && value !== '') {
+ const bytes = this.formatterService.toBytes(value);
+ if (
+ (!this.edit && bytes <= 0) ||
+ (this.edit && this.currentBytes && bytes <= this.currentBytes)
+ ) {
+ return { minSize: true };
+ }
+ }
+ return null;
+ })
+ ],
+ updateOn: 'blur'
+ }),
+ [NsFormField.NS_COUNT]: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
Validators.required,
Validators.max(this.MAX_NAMESPACE_CREATE),
Validators.min(this.MIN_NAMESPACE_CREATE)
- ])
+ ]),
+ [NsFormField.RBD_IMAGE_CREATION]: new UntypedFormControl(
+ RbdImageCreation.GATEWAY_PROVISIONED
+ ),
+
+ [NsFormField.RBD_IMAGE_NAME]: new UntypedFormControl(null, [
+ CdValidators.custom('rbdImageName', (value: any) => {
+ if (!value) return null;
+ return /^[^@/]+$/.test(value) ? null : { rbdImageName: true };
+ })
+ ]),
+ [NsFormField.NAMESPACE_SIZE]: new UntypedFormControl(null, [Validators.min(0)]), // sent as block_size in create request
+ [NsFormField.HOST_ACCESS]: new UntypedFormControl(HOST_TYPE.ALL), // drives no_auto_visible in create request
+ [NsFormField.INITIATORS]: new UntypedFormControl([]) // sent via addNamespaceInitiators API
});
+
+ this.nsForm
+ .get(NsFormField.POOL)
+ .valueChanges.pipe(takeUntil(this.destroy$))
+ .subscribe(() => {
+ this.onPoolChange();
+ });
+
+ this.nsForm
+ .get(NsFormField.NS_COUNT)
+ .valueChanges.pipe(takeUntil(this.destroy$))
+ .subscribe((count: number) => {
+ if (count > 1) {
+ const creationControl = this.nsForm.get(NsFormField.RBD_IMAGE_CREATION);
+ if (creationControl.value === RbdImageCreation.EXTERNALLY_MANAGED) {
+ creationControl.setValue(RbdImageCreation.GATEWAY_PROVISIONED);
+ }
+ }
+ });
+
+ this.nsForm
+ .get(NsFormField.RBD_IMAGE_CREATION)
+ .valueChanges.pipe(takeUntil(this.destroy$))
+ .subscribe((mode: string) => {
+ const nameControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME);
+ const countControl = this.nsForm.get(NsFormField.NS_COUNT);
+ const imageSizeControl = this.nsForm.get(NsFormField.IMAGE_SIZE);
+
+ if (mode === RbdImageCreation.EXTERNALLY_MANAGED) {
+ countControl.setValue(1);
+ countControl.disable();
+ this.onPoolChange();
+ nameControl.addValidators(Validators.required);
+ imageSizeControl.disable();
+ imageSizeControl.removeValidators(Validators.required);
+ } else {
+ countControl.enable();
+ nameControl.removeValidators(Validators.required);
+ imageSizeControl.enable();
+ imageSizeControl.addValidators(Validators.required);
+ }
+ nameControl.updateValueAndValidity();
+ imageSizeControl.updateValueAndValidity();
+ });
+
+ this.nsForm
+ .get(NsFormField.HOST_ACCESS)
+ .valueChanges.pipe(takeUntil(this.destroy$))
+ .subscribe((mode: string) => {
+ const initiatorsControl = this.nsForm.get(NsFormField.INITIATORS);
+ if (mode === HOST_TYPE.SPECIFIC) {
+ initiatorsControl.addValidators(Validators.required);
+ } else {
+ initiatorsControl.removeValidators(Validators.required);
+ initiatorsControl.setValue([]);
+ this.initiatorCandidates.forEach((i) => (i.selected = false));
+ }
+ initiatorsControl.updateValueAndValidity();
+ });
}
buildUpdateRequest(rbdImageSize: number): Observable<HttpResponse<Object>> {
return Math.random().toString(36).substring(2);
}
- buildCreateRequest(rbdImageSize: number, nsCount: number): Observable<HttpResponse<Object>>[] {
- const pool = this.nsForm.getValue('pool');
+ buildCreateRequest(
+ rbdImageSize: number,
+ nsCount: number,
+ noAutoVisible: boolean
+ ): Observable<HttpResponse<Object>>[] {
+ const pool = this.nsForm.getValue(NsFormField.POOL);
const requests: Observable<HttpResponse<Object>>[] = [];
+ const creationMode = this.nsForm.getValue(NsFormField.RBD_IMAGE_CREATION);
+ const isGatewayProvisioned = creationMode === RbdImageCreation.GATEWAY_PROVISIONED;
+
+ const loopCount = isGatewayProvisioned ? nsCount : 1;
- for (let i = 1; i <= nsCount; i++) {
+ for (let i = 1; i <= loopCount; i++) {
const request: NamespaceCreateRequest = {
gw_group: this.group,
- rbd_image_name: `nvme_${pool}_${this.group}_${this.randomString()}`,
rbd_pool: pool,
- create_image: true
+ create_image: isGatewayProvisioned,
+ no_auto_visible: noAutoVisible
};
- if (rbdImageSize) {
- request['rbd_image_size'] = rbdImageSize;
+
+ const blockSize = this.nsForm.getValue(NsFormField.NAMESPACE_SIZE);
+ if (blockSize) {
+ request.block_size = blockSize;
}
- requests.push(this.nvmeofService.createNamespace(this.subsystemNQN, request));
- }
- return requests;
- }
+ if (isGatewayProvisioned) {
+ request.rbd_image_name = `nvme_${pool}_${this.group}_${this.randomString()}`;
+ if (rbdImageSize) {
+ request['rbd_image_size'] = rbdImageSize;
+ }
+ }
+
+ const rbdImageName = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
+ if (rbdImageName) {
+ request['rbd_image_name'] = loopCount > 1 ? `${rbdImageName}-${i}` : rbdImageName;
+ }
- validateSize() {
- const unit = this.nsForm.getValue('unit');
- const image_size = this.nsForm.getValue('image_size');
- if (image_size && unit) {
- const bytes = this.formatterService.toBytes(image_size + unit);
- return bytes <= this.currentBytes;
+ const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM) || this.subsystemNQN;
+ requests.push(this.nvmeofService.createNamespace(subsystemNQN, request));
}
- return null;
+
+ return requests;
}
onSubmit() {
- if (this.validateSize()) {
- this.invalidSizeError = true;
+ if (this.nsForm.invalid) {
this.nsForm.setErrors({ cdSubmitButton: true });
+ this.nsForm.markAllAsTouched();
+ return;
+ }
+
+ const component = this;
+ const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`;
+ const image_size = this.nsForm.getValue(NsFormField.IMAGE_SIZE);
+ const nsCount = this.nsForm.getValue(NsFormField.NS_COUNT);
+ const hostAccess = this.nsForm.getValue(NsFormField.HOST_ACCESS);
+ const selectedHosts: string[] = this.nsForm.getValue(NsFormField.INITIATORS) || [];
+ const noAutoVisible = hostAccess === HOST_TYPE.SPECIFIC;
+ let action: Observable<any>;
+ let rbdImageSize: number = null;
+
+ if (image_size) {
+ rbdImageSize = this.formatterService.toBytes(image_size);
+ }
+
+ if (this.edit) {
+ action = this.taskWrapperService.wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ nqn: this.subsystemNQN,
+ nsid: this.nsid
+ }),
+ call: this.buildUpdateRequest(rbdImageSize)
+ });
} else {
- this.invalidSizeError = false;
- const component = this;
- const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`;
- const image_size = this.nsForm.getValue('image_size');
- const nsCount = this.nsForm.getValue('nsCount');
- let action: Observable<HttpResponse<Object>>;
- let rbdImageSize: number = null;
-
- if (image_size) {
- const image_size_unit = this.nsForm.getValue('unit');
- const value: number = this.formatterService.toBytes(image_size + image_size_unit);
- rbdImageSize = value;
- }
- if (this.edit) {
- action = this.taskWrapperService.wrapTaskAroundCall({
- task: new FinishedTask(taskUrl, {
- nqn: this.subsystemNQN,
- nsid: this.nsid
- }),
- call: this.buildUpdateRequest(rbdImageSize)
- });
- } else {
- action = this.taskWrapperService.wrapTaskAroundCall({
- task: new FinishedTask(taskUrl, {
- nqn: this.subsystemNQN,
- nsCount
- }),
- call: forkJoin(this.buildCreateRequest(rbdImageSize, nsCount))
- });
- }
+ const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM);
- action.subscribe({
- error() {
- component.nsForm.setErrors({ cdSubmitButton: true });
- },
- complete: () => {
- this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
- }
+ // Step 1: Create namespaces
+ // Step 2: If specific hosts selected, chain addNamespaceInitiators calls
+ const createObs = forkJoin(this.buildCreateRequest(rbdImageSize, nsCount, noAutoVisible));
+
+ const combinedObs = createObs.pipe(
+ switchMap((responses: HttpResponse<Object>[]) => {
+ if (noAutoVisible && selectedHosts.length > 0) {
+ const initiatorObs: Observable<any>[] = [];
+
+ responses.forEach((res) => {
+ const body: any = res.body;
+ if (body && body.nsid) {
+ selectedHosts.forEach((host: string) => {
+ const req: NamespaceInitiatorRequest = {
+ gw_group: this.group,
+ subsystem_nqn: subsystemNQN || this.subsystemNQN,
+ host_nqn: host
+ };
+ initiatorObs.push(this.nvmeofService.addNamespaceInitiators(body.nsid, req));
+ });
+ }
+ });
+
+ if (initiatorObs.length > 0) {
+ return forkJoin(initiatorObs);
+ }
+ }
+ return of(responses);
+ })
+ );
+
+ action = this.taskWrapperService.wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ nqn: subsystemNQN,
+ nsCount
+ }),
+ call: combinedObs
});
}
+
+ action.subscribe({
+ error: () => {
+ component.nsForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.router.navigate([this.pageURL], {
+ queryParams: { group: this.group, tab: 'namespace' }
+ });
+ }
+ });
}
}
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
+
import { CdTableAction } from '~/app/shared/models/cd-table-action';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { FinishedTask } from '~/app/shared/models/finished-task';
import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
name: this.actionLabels.CREATE,
permission: 'create',
icon: Icons.add,
- click: () =>
- this.router.navigate(
- [BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'namespace'] } }],
- { queryParams: { group: this.group } }
- ),
+ click: () => {
+ this.router.navigate(['block/nvmeof/namespaces/create'], {
+ queryParams: {
+ group: this.group,
+ subsystem_nqn: this.subsystemNQN
+ }
+ });
+ },
canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
disable: () => !this.group
},
this.router.navigate(
[
BASE_URL,
- {
- outlets: {
- modal: [
- URLVerbs.EDIT,
- this.subsystemNQN,
- 'namespace',
- this.selection.first().nsid
- ]
- }
- }
+ URLVerbs.EDIT,
+ this.selection.first().ns_subsystem_nqn,
+ 'namespace',
+ this.selection.first().nsid
],
{ queryParams: { group: this.group } }
)
}
return this.nvmeofService.listNamespaces(this.group).pipe(
map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) => {
- return Array.isArray(res) ? res : res.namespaces || [];
+ const namespaces = Array.isArray(res) ? res : res.namespaces || [];
+ // Deduplicate by nsid + subsystem NQN (API with wildcard can return duplicates per gateway)
+ const seen = new Set<string>();
+ return namespaces.filter((ns) => {
+ const key = `${ns.nsid}_${ns['ns_subsystem_nqn']}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
}),
catchError(() => of([]))
);
enable_ha: true,
initiators: '*',
gw_group: mockGroupName,
- dhchap_key: null
+ dhchap_key: ''
};
service.createSubsystem(request).subscribe();
const req = httpTesting.expectOne(`${API_PATH}/subsystem`);
);
expect(req.request.method).toBe('GET');
});
- it('should call addInitiators', () => {
+ it('should call addSubsystemInitiators', () => {
service.addSubsystemInitiators(mockNQN, request).subscribe();
const req = httpTesting.expectOne(`${UI_API_PATH}/subsystem/${mockNQN}/host`);
expect(req.request.method).toBe('POST');
});
- it('should call removeInitiators', () => {
+ it('should call removeSubsystemInitiators', () => {
service.removeSubsystemInitiators(mockNQN, request).subscribe();
const req = httpTesting.expectOne(
`${UI_API_PATH}/subsystem/${mockNQN}/host/${request.host_nqn}/${mockGroupName}`
import _ from 'lodash';
import { Observable, forkJoin, of as observableOf } from 'rxjs';
import { catchError, map, mapTo, mergeMap } from 'rxjs/operators';
+import { NvmeofSubsystemNamespace } from '../models/nvmeof';
import { CephServiceSpec } from '../models/service.interface';
import { HostService } from './host.service';
import { OrchestratorService } from './orchestrator.service';
};
export type NamespaceCreateRequest = NvmeofRequest & {
- rbd_image_name: string;
+ rbd_image_name?: string;
rbd_pool: string;
rbd_image_size?: number;
no_auto_visible?: boolean;
create_image: boolean;
+ block_size?: number;
};
export type NamespaceUpdateRequest = NvmeofRequest & {
}
);
}
+ listSubsystemNamespaces(subsystemNQN: string) {
+ return this.http.get<NvmeofSubsystemNamespace[]>(
+ `${API_PATH}/subsystem/${subsystemNQN}/namespace`
+ );
+ }
// Namespaces
listNamespaces(group: string, subsystemNQN: string = '*') {
return NO_AUTH;
}
+
+// Form control names for NvmeofNamespacesFormComponent
+export enum NsFormField {
+ POOL = 'pool',
+ SUBSYSTEM = 'subsystem',
+ IMAGE_SIZE = 'image_size',
+ NS_COUNT = 'nsCount',
+ RBD_IMAGE_CREATION = 'rbd_image_creation',
+ RBD_IMAGE_NAME = 'rbd_image_name',
+ NAMESPACE_SIZE = 'namespace_size',
+ HOST_ACCESS = 'host_access',
+ INITIATORS = 'initiators'
+}
+
+export enum RbdImageCreation {
+ GATEWAY_PROVISIONED = 'gateway_provisioned',
+ EXTERNALLY_MANAGED = 'externally_managed'
+}
+
+export type NvmeofNamespaceListResponse =
+ | NvmeofSubsystemNamespace[]
+ | { namespaces: NvmeofSubsystemNamespace[] };
+
+export type NvmeofInitiatorCandidate = {
+ content: string;
+ selected: boolean;
+};