import SubtractAlt from '@carbon/icons/es/subtract--alt/20';
import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
import Search from '@carbon/icons/es/search/32';
+import Datastore from '@carbon/icons/es/datastore/16';
import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component';
+import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component';
import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component';
import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver';
import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
NvmeGatewayViewComponent,
NvmeofGatewaySubsystemComponent,
NvmeofGatewayNodeAddModalComponent,
+ NvmeofNamespaceExpandModalComponent,
NvmeSubsystemViewComponent,
NvmeofEditHostKeyModalComponent
],
Reset,
ProgressBarRound,
SubtractAlt,
- Search
+ Search,
+ Datastore
]);
}
}
},
children: [
{ path: '', redirectTo: 'gateways', pathMatch: 'full' },
- { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } },
+ {
+ path: 'gateways',
+ component: NvmeofGatewayComponent,
+ data: { breadcrumbs: 'Gateways' },
+ children: [
+ {
+ path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
+ component: NvmeofNamespaceExpandModalComponent,
+ outlet: 'modal'
+ }
+ ]
+ },
{
path: `gateways/${URLVerbs.CREATE}`,
component: NvmeofGroupFormComponent,
data: { breadcrumbs: 'Subsystems' },
children: [
// subsystems
- { path: '', component: NvmeofSubsystemsComponent },
+
{
path: URLVerbs.CREATE,
component: NvmeofSubsystemsFormComponent,
},
{
path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
- component: NvmeofNamespacesFormComponent,
- data: { breadcrumbs: ActionLabels.EDIT + ' ' + $localize`Namespace` }
+ component: NvmeofNamespaceExpandModalComponent,
+ outlet: 'modal'
},
// initiators
{
<cd-nvmeof-namespaces-list></cd-nvmeof-namespaces-list>
</ng-template>
</section>
+<router-outlet name="modal"></router-outlet>
--- /dev/null
+<cds-modal size="sm"
+ [open]="true"
+ (overlaySelected)="closeModal()">
+ <cds-modal-header (closeSelect)="closeModal()">
+ <h4 cdsModalHeaderLabel
+ class="cds--type-label-01"
+ i18n>Namespace</h4>
+ <h3 cdsModalHeaderHeading
+ class="cds--type-heading-03"
+ i18n>Expand namespace</h3>
+ </cds-modal-header>
+
+ <section cdsModalContent>
+ <div class="cds--type-body-01 cds-mb-3"
+ i18n>
+ Increase the NVMe namespace storage capacity by resizing the backing image.
+ </div>
+
+ <div class="cds--type-body-01 cds-mb-5">
+ <div class="cds-mb-3">
+ <svg [cdsIcon]="icons.datastore"
+ size="16"
+ class="cds-mr-3"></svg>
+ <strong><span i18n>namespace</span>-{{ nsid }}</strong>
+ </div>
+ <div class="cds--type-helper-text-01 cds-mb-1">
+ <span i18n>Image:</span> {{ imageName }}
+ </div>
+ <div class="cds--type-helper-text-01">
+ <span i18n>Current size:</span> {{ currentBytes | dimlessBinary }}
+ </div>
+ </div>
+
+ <form name="nsForm"
+ #formDir="ngForm"
+ [formGroup]="nsForm"
+ novalidate>
+ <div class="form-item">
+ <cds-number label="Enter the new size of the namespace image (GiB)"
+ i18n-label
+ [formControlName]="'image_size'"
+ [invalid]="nsForm.controls['image_size'].invalid && (nsForm.controls['image_size'].dirty || nsForm.controls['image_size'].touched)"
+ [invalidText]="sizeErrorRef"
+ [min]="minSize"
+ required
+ modal-primary-focus></cds-number>
+ <ng-template #sizeErrorRef>
+ <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('image_size') }"></ng-container>
+ </ng-template>
+ </div>
+ </form>
+
+ <ng-template #validationErrors
+ let-control="control">
+ @for (err of control.errors | keyvalue; track err.key) {
+ <span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+ }
+ </ng-template>
+ </section>
+
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [submitText]="expandText"
+ [modalForm]="true"></cd-form-button-panel>
+</cds-modal>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal.component';
+import { ActivatedRoute } from '@angular/router';
+import { ActivatedRouteStub } from '~/testing/activated-route-stub';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { SharedModule } from '~/app/shared/shared.module';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { ModalModule, NumberModule } from 'carbon-components-angular';
+import { of } from 'rxjs';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+
+describe('NvmeofNamespaceExpandModalComponent', () => {
+ let component: NvmeofNamespaceExpandModalComponent;
+ let fixture: ComponentFixture<NvmeofNamespaceExpandModalComponent>;
+ let nvmeofService: NvmeofService;
+
+ const mockNvmeofService = {
+ getNamespace: () =>
+ of({
+ nsid: '1',
+ rbd_pool_name: 'pool1',
+ rbd_image_name: 'image1',
+ rbd_image_size: new FormatterService().toBytes('1GiB'),
+ block_size: 4096,
+ rw_ios_per_second: 0,
+ rw_mbytes_per_second: 0,
+ r_mbytes_per_second: 0,
+ w_mbytes_per_second: 0
+ }),
+ updateNamespace: () => of({})
+ };
+
+ const activatedRouteStub = new ActivatedRouteStub(
+ { subsystem_nqn: 'nqn.2014-08.org.nvmexpress:uuid:12345', nsid: '1' },
+ { group: 'group1' }
+ );
+ // Mock the parent route for relative navigation
+ Object.defineProperty(activatedRouteStub, 'parent', { get: () => ({}) });
+
+ configureTestBed({
+ declarations: [NvmeofNamespaceExpandModalComponent],
+ imports: [
+ HttpClientTestingModule,
+ SharedModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ ToastrModule.forRoot(),
+ ModalModule,
+ NumberModule
+ ],
+ providers: [
+ { provide: NvmeofService, useValue: mockNvmeofService },
+ { provide: ActivatedRoute, useValue: activatedRouteStub }
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NvmeofNamespaceExpandModalComponent);
+ component = fixture.componentInstance;
+ nvmeofService = TestBed.inject(NvmeofService);
+
+ // params are already set in constructor of stub above
+
+ spyOn(nvmeofService, 'getNamespace').and.callThrough();
+ spyOn(nvmeofService, 'updateNamespace').and.callThrough();
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize form with existing data', () => {
+ expect(component.nsForm.get('image_size').value).toBe(1);
+ });
+
+ it('should validate size - error if smaller', () => {
+ component.nsForm.get('image_size').setValue(0.5);
+ component.nsForm.get('image_size').updateValueAndValidity();
+ expect(component.nsForm.get('image_size').hasError('minSize')).toBe(true);
+ });
+
+ it('should validate size - success if larger', () => {
+ component.nsForm.get('image_size').setValue(2);
+ component.nsForm.get('image_size').updateValueAndValidity();
+ expect(component.nsForm.get('image_size').hasError('minSize')).toBe(false);
+ });
+});
--- /dev/null
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { combineLatest } from 'rxjs';
+
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NvmeofService, NamespaceUpdateRequest } from '~/app/shared/api/nvmeof.service';
+import { ActionLabelsI18n } 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 { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { FormButtonPanelComponent } from '~/app/shared/components/form-button-panel/form-button-panel.component';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+
+@Component({
+ selector: 'cd-nvmeof-namespace-expand-modal',
+ templateUrl: './nvmeof-namespace-expand-modal.component.html',
+ styleUrls: ['./nvmeof-namespace-expand-modal.component.scss'],
+ standalone: false
+})
+export class NvmeofNamespaceExpandModalComponent implements OnInit {
+ subsystemNQN: string;
+ nsid: string;
+ group: string;
+
+ nsForm: CdFormGroup;
+ currentBytes: number;
+ currentSizeGiB: number;
+ imageName: string;
+ expandText: string = $localize`Expand`;
+ icons = Icons;
+ INVALID_TEXTS: Record<string, string> = {
+ required: $localize`This field is required.`,
+ minSize: $localize`Value must be greater than the current image size.`
+ };
+
+ minSize: number = 0;
+
+ @ViewChild(FormButtonPanelComponent)
+ formButtonPanel: FormButtonPanelComponent;
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private nvmeofService: NvmeofService,
+ private route: ActivatedRoute,
+ private router: Router,
+ public taskWrapper: TaskWrapperService,
+ private formatter: FormatterService
+ ) {}
+
+ ngOnInit() {
+ this.createForm();
+ combineLatest([this.route.params, this.route.queryParams]).subscribe(
+ ([params, queryParams]) => {
+ this.subsystemNQN = params['subsystem_nqn'];
+ this.nsid = params['nsid'];
+ this.group = queryParams['group'];
+
+ if (this.subsystemNQN && this.nsid && this.group) {
+ this.initForEdit();
+ }
+ }
+ );
+ }
+
+ createForm() {
+ this.nsForm = new CdFormGroup({
+ image_size: new UntypedFormControl(null, {
+ validators: [
+ Validators.required,
+ CdValidators.custom('minSize', (value: any) => {
+ if (this.currentBytes && value !== null && value !== undefined) {
+ const bytes = this.formatter.toBytes(`${value}GiB`);
+ if (bytes <= this.currentBytes) {
+ return { minSize: true };
+ }
+ }
+ return null;
+ })
+ ]
+ })
+ });
+ }
+
+ initForEdit() {
+ this.nvmeofService
+ .getNamespace(this.subsystemNQN, this.nsid, this.group)
+ .subscribe((res: NvmeofSubsystemNamespace) => {
+ this.currentBytes =
+ typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
+ this.imageName = res.rbd_image_name;
+ this.currentSizeGiB = this.currentBytes / this.formatter.toBytes('1GiB');
+ this.minSize = this.currentSizeGiB;
+ this.nsForm.patchValue({
+ image_size: this.currentSizeGiB
+ });
+ this.nsForm.get('image_size').updateValueAndValidity();
+ });
+ }
+
+ closeModal() {
+ this.router.navigate([{ outlets: { modal: null } }], {
+ relativeTo: this.route.parent,
+ queryParamsHandling: 'preserve'
+ });
+ }
+
+ onSubmit() {
+ if (this.nsForm.invalid) {
+ this.nsForm.markAllAsTouched();
+ this.nsForm.setErrors({ cdSubmitButton: true });
+ if (this.formButtonPanel?.submitButton) {
+ this.formButtonPanel.submitButton.loading = false;
+ }
+ return;
+ }
+
+ const image_size = this.nsForm.getValue('image_size');
+ const rbdImageSize = this.formatter.toBytes(`${image_size}GiB`);
+
+ const request: NamespaceUpdateRequest = {
+ gw_group: this.group,
+ rbd_image_size: rbdImageSize
+ };
+
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('nvmeof/namespace/edit', {
+ nqn: this.subsystemNQN,
+ nsid: this.nsid
+ }),
+ call: this.nvmeofService.updateNamespace(this.subsystemNQN, this.nsid, request)
+ })
+ .subscribe({
+ complete: () => {
+ this.closeModal();
+ }
+ });
+ }
+}
<form
+ name="nsForm"
+ #formDir="ngForm"
[formGroup]="nsForm"
novalidate>
<div cdsGrid
<div cdsRow
class="form-heading form-item">
<h3>{{ action | titlecase }} {{ resource | titlecase }}</h3>
- <cd-help-text [formAllFieldsRequired]="true">
+ <cd-help-text>
<span i18n>
Namespaces define the storage volumes that subsystems present to hosts.
</span>
</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"
+ [invalid]="nsForm.controls['nsCount'].invalid && (nsForm.controls['nsCount'].dirty || nsForm.controls['nsCount'].touched)"
+ [invalidText]="nsForm.getError('required', 'nsCount') ? requiredInvalidText : nsCountInvalidText"
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) {
+ <!-- Namespace Size (UI only) -->
<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."
+ [invalidText]="namespaceSizeError"
i18n-label
- i18n-helperText
- i18n-invalidText>
+ i18n-helperText>
</cds-number>
+ <ng-template #namespaceSizeError>
+ @if (nsForm.controls['namespace_size'].hasError('blockSizeMultiple')) {
+ <span
+ i18n>Size must be a multiple of the block size (typically 512 or 4096 bytes).</span>
+ }
+ </ng-template>
</div>
</div>
- }
- <!-- Host Access (drives no_auto_visible in create request) -->
- @if (!edit) {
+ <!-- Host Access (UI only) -->
<div cdsRow
class="form-item">
<div cdsCol>
</div>
</div>
</div>
- }
<!-- Host Selection (Visible only when 'specific' is selected) -->
- @if (!edit && nsForm.getValue('host_access') === 'specific') {
+ @if (nsForm.getValue('host_access') === 'specific') {
<div cdsRow
class="form-item">
<div cdsCol>
[items]="initiatorCandidates"
(selected)="onInitiatorSelection($event)"
[invalid]="nsForm.controls['initiators'].invalid && (nsForm.controls['initiators'].dirty || nsForm.controls['initiators'].touched)"
- invalidText="This field is required."
+ invalidText="This field is required"
i18n-invalidText
i18n-placeholder>
<cds-dropdown-list></cds-dropdown-list>
<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."
+ [invalid]="nsForm.controls['subsystem'].invalid && (nsForm.controls['subsystem'].dirty || nsForm.controls['subsystem'].touched)"
+ invalidText="This field is required"
i18n-label
i18n-invalidText>
@if (subsystems === undefined) {
}
@if (subsystems && subsystems.length > 0) {
<option
+ selectionFeedback="top-after-reopen"
value=""
selected>Select a subsystem</option>
}
[value]="subsystem.nqn">{{ subsystem.nqn }}</option>
}
</cds-select>
- }
</div>
</div>
<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."
+ [invalid]="nsForm.controls['pool'].invalid && (nsForm.controls['pool'].dirty || nsForm.controls['pool'].touched)"
+ invalidText="This field is required"
helperText="Pool where the backing Ceph block device resides."
i18n-label
i18n-invalidText
[value]="pool.pool_name">{{ pool.pool_name }}</option>
}
</cds-select>
- }
</div>
</div>
- <!-- RBD image creation (drives create_image flag in request) -->
- @if (!edit) {
+ <!-- RBD image creation (UI only) -->
<div cdsRow
class="form-item">
<div cdsCol>
</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) {
+ <!-- Image Name (Visible only when 'gateway_provisioned' is selected) -->
+ @if (nsForm.getValue('rbd_image_creation') === 'gateway_provisioned' && nsForm.getValue('nsCount') > 1) {
<div cdsRow
class="form-item">
<div cdsCol>
<strong i18n>For bulk namespace creation, RBD images are provisioned automatically.</strong>
</cd-alert-panel>
- <div class="form-item"
- cdOptionalField="Image name">
+ <div class="form-item">
<label
- class="cds--type-label-01 cds--label"
- i18n>Image name</label>
+ class="cds--type-label-01"
+ i18n>Image name (optional)</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>
+ [invalid]="nsForm.controls['rbd_image_name'].invalid && (nsForm.controls['rbd_image_name'].dirty || nsForm.controls['rbd_image_name'].touched)"
+ [invalidText]="nsForm.getError('rbdImageName', 'rbd_image_name') ? 'Image name contains invalid characters' : ''"
+ i18n-helperText
+ i18n-invalidText>
<input cdsText
placeholder="Enter a name"
- formControlName="rbd_image_name"
- cdValidate
- #rbdImageNameRef="cdValidate"
- [invalid]="rbdImageNameRef.isInvalid" />
+ formControlName="rbd_image_name" />
</cds-text-label>
- <ng-template #rbdImageNameError>
- <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('rbd_image_name') }"></ng-container>
- </ng-template>
</div>
</div>
</div>
}
<!-- Image Selection (Visible only when 'externally_managed' is selected) -->
- @if (!edit && nsForm.getValue('rbd_image_creation') === 'externally_managed') {
+ @if (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."
+ [invalid]="nsForm.controls['rbd_image_name'].invalid && (nsForm.controls['rbd_image_name'].dirty || nsForm.controls['rbd_image_name'].touched)"
+ invalidText="This field is required"
helperText="Select an existing RBD image from the pool to expose as a namespace."
i18n-label
i18n-invalidText
}
<!-- Image Size -->
- @if (!edit && nsForm.getValue('rbd_image_creation') !== 'externally_managed') {
<div cdsRow
class="form-item">
<div
cdsCol
[columnNumbers]="{md: 4}">
<cds-text-label
+ label="Image size (GiB)"
helperText="The size of the namespace image."
- [invalid]="imageSizeRef.isInvalid"
+ [invalid]="nsForm.controls['image_size'].invalid && (nsForm.controls['image_size'].dirty || nsForm.controls['image_size'].touched)"
[invalidText]="sizeError"
+ cdRequiredField="Image Size"
+ i18n-label
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"
+ [invalid]="nsForm.controls['image_size'].invalid && (nsForm.controls['image_size'].dirty || nsForm.controls['image_size'].touched)"
cdDimlessBinary>
</cds-text-label>
<ng-template #sizeError>
- <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('image_size') }"></ng-container>
+ <span
+ i18n>Enter a valid size (e.g., 10GiB).</span>
</ng-template>
</div>
</div>
- }
<div cdsRow>
<cd-form-button-panel
</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 { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { SharedModule } from '~/app/shared/shared.module';
-
import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form.component';
import { FormHelper, Mocks } from '~/testing/unit-test-helper';
+import { FormatterService } from '~/app/shared/services/formatter.service';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { of, Observable } from 'rxjs';
import { PoolService } from '~/app/shared/api/pool.service';
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']),
rbd_image_name: 'nvme_rbd_default_sscfagwuvvr',
rbd_pool_name: 'rbd',
load_balancing_group: 1,
- rbd_image_size: '1073741824',
+ rbd_image_size: new FormatterService().toBytes('1GiB').toString(),
block_size: 512,
rw_ios_per_second: '0',
rw_mbytes_per_second: '0',
});
it('should create 5 namespaces correctly', () => {
formHelper.setValue('pool', 'rbd');
- formHelper.setValue('image_size', 1073741824);
+ formHelper.setValue('image_size', new FormatterService().toBytes('1GiB'));
formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
component.onSubmit();
expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(5);
rbd_image_name: `nvme_rbd_default_${MOCK_RANDOM_STRING}`,
rbd_pool: 'rbd',
create_image: true,
- rbd_image_size: 1073741824,
+ rbd_image_size: new FormatterService().toBytes('1GiB'),
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();
it('should call addNamespaceInitiators on submit with specific hosts', () => {
formHelper.setValue('pool', 'rbd');
- formHelper.setValue('image_size', 1073741824);
+ formHelper.setValue('image_size', new FormatterService().toBytes('1GiB'));
formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
formHelper.setValue('host_access', 'specific');
formHelper.setValue('initiators', ['host1']);
});
it('should update initiators form control on selection', () => {
- const mockEvent: NvmeofInitiatorCandidate[] = [
- { content: 'host1', selected: true },
- { content: 'host2', selected: true }
- ];
+ 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);
});
});
- describe('should test edit form', () => {
- beforeEach(() => {
- router = TestBed.inject(Router);
- nvmeofService = TestBed.inject(NvmeofService);
- spyOn(nvmeofService, 'getNamespace').and.returnValue(of(MOCK_NS_RESPONSE));
- 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);
- 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 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.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('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 GiB');
- component.onSubmit();
- expect(nvmeofService.updateNamespace).toHaveBeenCalledTimes(1);
- expect(nvmeofService.updateNamespace).toHaveBeenCalledWith(MOCK_SUBSYSTEM, MOCK_NSID, {
- gw_group: MOCK_GROUP,
- rbd_image_size: 2147483648
- });
- });
- });
});
-import { Component, OnDestroy, OnInit } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
import { UntypedFormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import {
NamespaceCreateRequest,
NamespaceInitiatorRequest,
- NamespaceUpdateRequest,
NvmeofService
} from '~/app/shared/api/nvmeof.service';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import {
NvmeofSubsystem,
NvmeofSubsystemInitiator,
- NvmeofSubsystemNamespace,
- NvmeofNamespaceListResponse,
- NvmeofInitiatorCandidate,
- NsFormField,
- RbdImageCreation,
- HOST_TYPE
+ NvmeofSubsystemNamespace
} 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, of, Subject } from 'rxjs';
-import { filter, switchMap, takeUntil, tap } from 'rxjs/operators';
+import { forkJoin, Observable, of } from 'rxjs';
+import { switchMap } 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, OnDestroy {
+export class NvmeofNamespacesFormComponent implements OnInit {
action: string;
permission: Permission;
poolPermission: Permission;
resource: string;
pageURL: string;
- edit: boolean = false;
+
nsForm: CdFormGroup;
subsystemNQN: string;
- 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;
+ subsystems: NvmeofSubsystem[];
+ rbdPools: Array<Pool> = null;
+ rbdImages: any[] = [];
+ initiatorCandidates: { content: string; selected: boolean }[] = [];
nsid: string;
- currentBytes: number = 0;
+
group: string;
MAX_NAMESPACE_CREATE: number = 5;
MIN_NAMESPACE_CREATE: number = 1;
- 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.`
- };
+ requiredInvalidText: string = $localize`This field is required`;
+ nsCountInvalidText: string = $localize`The namespace count should be between 1 and 5`;
constructor(
public actionLabels: ActionLabelsI18n,
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.subsystemNQN = params.subsystem_nqn;
this.nsid = params?.nsid;
});
- }
-
- initForEdit() {
- this.edit = true;
- this.action = this.actionLabels.EDIT;
- this.nvmeofService
- .getNamespace(this.subsystemNQN, this.nsid, this.group)
- .subscribe((res: NvmeofSubsystemNamespace) => {
- this.currentBytes =
- typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
- 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);
- });
+ this.route.queryParams.subscribe((params) => {
+ if (params?.['subsystem_nqn']) {
+ this.subsystemNQN = params?.['subsystem_nqn'];
+ }
+ });
}
initForCreate() {
this.poolService.getList().subscribe((resp: Pool[]) => {
this.rbdPools = resp.filter(this.rbdService.isRBDPool);
});
- 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[]) => {
+ if (this.group) {
+ this.fetchUsedImages();
+ 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);
+ this.nsForm.get('subsystem').setValue(selectedSubsystem.nqn);
}
}
});
+ }
}
ngOnInit() {
this.init();
- if (this.router.url.includes('subsystems/(modal:edit')) {
- this.initForEdit();
- } else {
- this.initForCreate();
- }
- const subsystemControl = this.nsForm.get(NsFormField.SUBSYSTEM);
+ this.initForCreate();
+ const subsystemControl = this.nsForm.get('subsystem');
if (subsystemControl) {
subsystemControl.valueChanges.subscribe((nqn: string) => {
this.onSubsystemChange(nqn);
}
}
+ // Stores all RBD images fetched for the selected pool
+ private allRbdImages: { name: string; size: number }[] = [];
+ // Maps pool name to a Set of used image names for O(1) lookup
+ private usedRbdImages: Map<string, Set<string>> = new Map();
+
onPoolChange(): void {
- const pool = this.nsForm.getValue(NsFormField.POOL);
+ const pool = this.nsForm.getValue('pool');
if (!pool) return;
this.rbdService
.list({ pool_name: pool, offset: '0', limit: '-1' })
- .subscribe((pools: RbdPool[]) => {
+ .subscribe((pools: { pool_name: string; value: { name: string; size: number }[] }[]) => {
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);
+ const imageControl = this.nsForm.get('rbd_image_name');
+ const currentImage = this.nsForm.getValue('rbd_image_name');
if (currentImage && !this.rbdImages.some((img) => img.name === currentImage)) {
imageControl.setValue(null);
}
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();
- });
+ this.nvmeofService.listNamespaces(this.group).subscribe((response: any) => {
+ 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;
+ if (!nqn) return;
this.nvmeofService
.getInitiators(nqn, this.group)
.subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
});
}
- onInitiatorSelection(event: NvmeofInitiatorCandidate[]) {
+ onInitiatorSelection(event: any) {
// Carbon ComboBox (selected) emits the full array of selected items
- const selectedInitiators = Array.isArray(event) ? event.map((e) => e.content) : [];
+ const selectedInitiators = Array.isArray(event) ? event.map((e: any) => e.content) : [];
this.nsForm
- .get(NsFormField.INITIATORS)
+ .get('initiators')
.setValue(selectedInitiators.length > 0 ? selectedInitiators : null);
- this.nsForm.get(NsFormField.INITIATORS).markAsDirty();
- this.nsForm.get(NsFormField.INITIATORS).markAsTouched();
+ this.nsForm.get('initiators').markAsDirty();
+ this.nsForm.get('initiators').markAsTouched();
}
private filterImages(): void {
- const pool = this.nsForm.getValue(NsFormField.POOL);
+ const pool = this.nsForm.getValue('pool');
if (!pool) {
this.rbdImages = [];
return;
createForm() {
this.nsForm = new CdFormGroup({
- [NsFormField.POOL]: new UntypedFormControl('', {
+ pool: new UntypedFormControl('', {
validators: [Validators.required]
}),
- [NsFormField.SUBSYSTEM]: new UntypedFormControl('', {
+ 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;
- })
- ],
+ image_size: new UntypedFormControl(null, {
+ validators: [Validators.required],
updateOn: 'blur'
}),
- [NsFormField.NS_COUNT]: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
+ nsCount: 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
- ),
+ rbd_image_creation: new UntypedFormControl('gateway_provisioned'),
- [NsFormField.RBD_IMAGE_NAME]: new UntypedFormControl(null, [
+ 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
+ namespace_size: new UntypedFormControl(null, {
+ validators: [CdValidators.blockSizeMultiple()]
+ }), // UI only - not sent to backend
+ host_access: new UntypedFormControl('all'), // UI only - determines visibility
+ initiators: new UntypedFormControl([]) // UI only - selected hosts
});
- this.nsForm
- .get(NsFormField.POOL)
- .valueChanges.pipe(takeUntil(this.destroy$))
- .subscribe(() => {
- this.onPoolChange();
- });
+ this.nsForm.get('pool').valueChanges.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('nsCount').valueChanges.subscribe((count: number) => {
+ if (count > 1) {
+ const creationControl = this.nsForm.get('rbd_image_creation');
+ if (creationControl.value === 'externally_managed') {
+ creationControl.setValue('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('rbd_image_creation').valueChanges.subscribe((mode: string) => {
+ const nameControl = this.nsForm.get('rbd_image_name');
+ const sizeControl = this.nsForm.get('image_size');
+ const countControl = this.nsForm.get('nsCount');
- 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();
- });
- }
+ if (mode === 'externally_managed') {
+ countControl.setValue(1);
+ countControl.disable();
+ this.onPoolChange();
+ nameControl.addValidators(Validators.required);
+ sizeControl.removeValidators(Validators.required);
+ sizeControl.disable();
+ } else {
+ sizeControl.enable();
+ countControl.enable();
+ nameControl.removeValidators(Validators.required);
+ sizeControl.addValidators(Validators.required);
+ }
+ nameControl.updateValueAndValidity();
+ sizeControl.updateValueAndValidity();
+ });
- buildUpdateRequest(rbdImageSize: number): Observable<HttpResponse<Object>> {
- const request: NamespaceUpdateRequest = {
- gw_group: this.group,
- rbd_image_size: rbdImageSize
- };
- return this.nvmeofService.updateNamespace(
- this.subsystemNQN,
- this.nsid,
- request as NamespaceUpdateRequest
- );
+ this.nsForm.get('host_access').valueChanges.subscribe((mode: string) => {
+ const initiatorsControl = this.nsForm.get('initiators');
+ if (mode === 'specific') {
+ initiatorsControl.addValidators(Validators.required);
+ } else {
+ initiatorsControl.removeValidators(Validators.required);
+ initiatorsControl.setValue([]);
+ this.initiatorCandidates.forEach((i) => (i.selected = false));
+ }
+ initiatorsControl.updateValueAndValidity();
+ });
}
randomString() {
nsCount: number,
noAutoVisible: boolean
): Observable<HttpResponse<Object>>[] {
- const pool = this.nsForm.getValue(NsFormField.POOL);
+ const pool = this.nsForm.getValue('pool');
const requests: Observable<HttpResponse<Object>>[] = [];
- const creationMode = this.nsForm.getValue(NsFormField.RBD_IMAGE_CREATION);
- const isGatewayProvisioned = creationMode === RbdImageCreation.GATEWAY_PROVISIONED;
+ const creationMode = this.nsForm.getValue('rbd_image_creation');
+ const isGatewayProvisioned = creationMode === 'gateway_provisioned';
const loopCount = isGatewayProvisioned ? nsCount : 1;
no_auto_visible: noAutoVisible
};
- const blockSize = this.nsForm.getValue(NsFormField.NAMESPACE_SIZE);
+ const blockSize = this.nsForm.getValue('namespace_size');
if (blockSize) {
request.block_size = blockSize;
}
}
}
- const rbdImageName = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
+ const rbdImageName = this.nsForm.getValue('rbd_image_name');
if (rbdImageName) {
- request['rbd_image_name'] = loopCount > 1 ? `${rbdImageName}-${i}` : rbdImageName;
+ request['rbd_image_name'] = rbdImageName;
}
- const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM) || this.subsystemNQN;
+ const subsystemNQN = this.nsForm.getValue('subsystem') || this.subsystemNQN;
requests.push(this.nvmeofService.createNamespace(subsystemNQN, request));
}
}
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;
+ const taskUrl: string = `nvmeof/namespace/${URLVerbs.CREATE}`;
+ const image_size = this.nsForm.getValue('image_size');
+ const nsCount = this.nsForm.getValue('nsCount');
+ const hostAccess = this.nsForm.getValue('host_access');
+ const selectedHosts: string[] = this.nsForm.getValue('initiators') || [];
+ const noAutoVisible = hostAccess === 'specific';
let action: Observable<any>;
let rbdImageSize: number = null;
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 {
- const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM);
-
- // 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);
+ const subsystemNQN = this.nsForm.getValue('subsystem');
+
+ // 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
- });
- }
+ }
+ return of(responses);
+ })
+ );
+
+ action = this.taskWrapperService.wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ nqn: subsystemNQN,
+ nsCount
+ }),
+ call: combinedObs
+ });
action.subscribe({
error: () => {
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
-const BASE_URL = 'block/nvmeof/subsystems';
const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
@Component({
disable: () => !this.group
},
{
- name: this.actionLabels.EDIT,
+ name: $localize`Expand`,
permission: 'update',
icon: Icons.edit,
click: () =>
this.router.navigate(
[
- BASE_URL,
- URLVerbs.EDIT,
- this.selection.first().ns_subsystem_nqn,
- 'namespace',
- this.selection.first().nsid
+ {
+ outlets: {
+ modal: [
+ URLVerbs.EDIT,
+ this.selection.first().ns_subsystem_nqn,
+ 'namespace',
+ this.selection.first().nsid
+ ]
+ }
+ }
],
- { queryParams: { group: this.group } }
+ {
+ relativeTo: this.route,
+ queryParams: { group: this.group },
+ queryParamsHandling: 'merge'
+ }
)
},
{
connect = 'connect',
checkmarkOutline = 'checkmark--outline',
circleDash = 'circle-dash',
+ datastore = 'datastore',
/* Icons for special effect */
size16 = '16',
size20 = '20',
};
}
+ /**
+ * Validator function to ensure the entered value is a multiple of a typical block size (512 or 4096).
+ * It checks the numeric value directly against the modulo 512 calculation.
+ */
+ static blockSizeMultiple(): ValidatorFn {
+ return (control: AbstractControl): { [key: string]: boolean } | null => {
+ const value = control.value;
+ if (value !== null && value !== undefined && value !== '') {
+ if (Number(value) % 512 !== 0) {
+ return { blockSizeMultiple: true };
+ }
+ }
+ return null;
+ };
+ }
+
/**
* Asynchronous validator that checks if the password meets the password
* policy.