path: `${URLVerbs.ADD}/initiator`,
component: NvmeofInitiatorsFormComponent,
outlet: 'modal'
+ },
+ {
+ path: `${URLVerbs.ADD}/listener`,
+ component: NvmeofListenersFormComponent,
+ outlet: 'modal'
}
]
}
})
export class NvmeSubsystemViewBreadcrumbResolver extends BreadcrumbsResolver {
resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] {
- const subsystemNQN = route.parent?.params?.subsystem_nqn || route.params?.subsystem_nqn;
- return [{ text: decodeURIComponent(subsystemNQN || ''), path: null }];
+ const subsystemNQN = route.parent?.params?.subsystem_nqn || route.params?.subsystem_nqn || '';
+ let decodedNQN = subsystemNQN;
+ try {
+ decodedNQN = decodeURIComponent(subsystemNQN);
+ } catch (e) {
+ // Fallback to raw value if decoding fails
+ }
+ return [{ text: decodedNQN, path: null }];
}
}
-import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
-import { SideNavModule, ThemeModule } from 'carbon-components-angular';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
import { NvmeSubsystemViewComponent } from './nvme-subsystem-view.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
let component: NvmeSubsystemViewComponent;
let fixture: ComponentFixture<NvmeSubsystemViewComponent>;
+ const mockParamMap = {
+ get: (key: string) => (key === 'subsystem_nqn' ? 'nqn.test' : null)
+ };
+ const mockQueryParams = { group: 'my-group' };
+
+ const mockActivatedRoute = {
+ paramMap: of(mockParamMap),
+ queryParams: of(mockQueryParams)
+ };
+
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [NvmeSubsystemViewComponent],
- imports: [RouterTestingModule, SideNavModule, ThemeModule, HttpClientTestingModule],
+ imports: [RouterTestingModule, HttpClientTestingModule],
+ providers: [{ provide: ActivatedRoute, useValue: mockActivatedRoute }],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
})
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('should build sidebar items correctly', () => {
+ expect(component.sidebarItems.length).toBe(3);
+
+ // Verify first item (Initiators)
+ expect(component.sidebarItems[0].route).toEqual([
+ '/block/nvmeof/subsystems',
+ 'nqn.test',
+ 'hosts'
+ ]);
+ expect(component.sidebarItems[0].routeExtras).toEqual({ queryParams: { group: 'my-group' } });
+
+ // Verify second item (Namespaces)
+ expect(component.sidebarItems[1].route).toEqual([
+ '/block/nvmeof/subsystems',
+ 'nqn.test',
+ 'namespaces'
+ ]);
+
+ // Verify third item (Listeners)
+ expect(component.sidebarItems[2].route).toEqual([
+ '/block/nvmeof/subsystems',
+ 'nqn.test',
+ 'listeners'
+ ]);
+ });
});
call: this.nvmeofService.addInitiators(this.subsystemNQN, request)
})
.subscribe({
- error: (err) => {
+ error: () => {
this.isSubmitLoading = false;
- err.preventDefault();
},
complete: () => {
this.isSubmitLoading = false;
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 { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { FinishedTask } from '~/app/shared/models/finished-task';
import {
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { NvmeofEditHostKeyModalComponent } from '../nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component';
-const BASE_URL = 'block/nvmeof/subsystems';
-
@Component({
selector: 'cd-nvmeof-initiators-list',
templateUrl: './nvmeof-initiators-list.component.html',
@ViewChild('dhchapTpl', { static: true })
dhchapTpl: TemplateRef<any>;
- initiatorColumns: any;
+ initiatorColumns: CdTableColumn[];
tableActions: CdTableAction[];
selection = new CdTableSelection();
permission: Permission;
return this.initiators.some((initiator) => initiator.nqn === '*');
}
- editHostAccess() {
- this.router.navigate(
- [BASE_URL, { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } }],
- { queryParams: { group: this.group } }
- );
- }
-
updateSelection(selection: CdTableSelection) {
this.selection = selection;
}
-<cd-modal [pageURL]="pageURL">
- <span class="modal-title"
- i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
- <ng-container class="modal-content">
- <form name="listenerForm"
- #formDir="ngForm"
- [formGroup]="listenerForm"
- novalidate>
- <div class="modal-body">
- <!-- Host -->
- <div class="form-group row">
- <label class="cd-col-form-label"
- for="host">
- <span class="required"
- i18n>Hostname</span>
- </label>
- <div class="cd-col-form-input">
- <select id="host"
- name="host"
- class="form-select"
- formControlName="host">
- <option *ngIf="hosts === null"
- [ngValue]="null"
- i18n>Loading...</option>
- <option *ngIf="hosts && hosts.length === 0"
- [ngValue]="null"
- i18n>-- No hosts available --</option>
- <option *ngIf="hosts && hosts.length > 0"
- [ngValue]="null"
- i18n>-- Select a host --</option>
- <option *ngFor="let hostsItem of hosts"
- [ngValue]="hostsItem">{{ hostsItem.hostname }}</option>
- </select>
- <cd-help-text i18n>
- This hostname uniquely identifies the gateway on which the listener is being set up.
- </cd-help-text>
- <span class="invalid-feedback"
- *ngIf="listenerForm.showError('host', formDir, 'required')"
- i18n>This field is required.</span>
- </div>
- </div>
- <!-- Transport Service ID -->
- <div class="form-group row">
- <label class="cd-col-form-label"
- for="trsvcid">
- <span i18n>Transport Service ID</span>
- </label>
- <div class="cd-col-form-input">
- <input id="trsvcid"
- class="form-control"
- type="text"
- name="trsvcid"
- formControlName="trsvcid">
- <cd-help-text i18n>The IP port to use. Default is 4420.</cd-help-text>
- <span class="invalid-feedback"
- *ngIf="listenerForm.showError('trsvcid', formDir, 'required')"
- i18n>This field is required.</span>
- <span class="invalid-feedback"
- *ngIf="listenerForm.showError('trsvcid', formDir, 'max')"
- i18n>The value cannot be greated than 65535.</span>
- <span class="invalid-feedback"
- *ngIf="listenerForm.showError('trsvcid', formDir, 'pattern')"
- i18n>The value must be a positive integer.</span>
- </div>
- </div>
- </div>
- <div class="modal-footer">
- <div class="text-right">
- <cd-form-button-panel (submitActionEvent)="onSubmit()"
- [form]="listenerForm"
- [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
- </div>
- </div>
- </form>
- </ng-container>
-</cd-modal>
+<cd-tearsheet
+ [steps]="steps"
+ [title]="title"
+ [description]="description"
+ size="sm"
+ (submitRequested)="onSubmit($event)"
+ [isSubmitLoading]="isSubmitLoading"
+ submitButtonLabel="Add"
+ i18n-submitButtonLabel>
+
+ <cd-tearsheet-step>
+ <cd-nvmeof-subsystem-step-one
+ #tearsheetStep
+ modal-primary-focus
+ [group]="group"
+ [subsystemNQN]="subsystemNQN"
+ [listenersOnly]="true"></cd-nvmeof-subsystem-step-one>
+ </cd-tearsheet-step>
+</cd-tearsheet>
import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ReactiveFormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ToastrModule } from 'ngx-toastr';
-import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { SharedModule } from '~/app/shared/shared.module';
import { NvmeofListenersFormComponent } from './nvmeof-listeners-form.component';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
describe('NvmeofListenersFormComponent', () => {
let component: NvmeofListenersFormComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [NvmeofListenersFormComponent],
- providers: [NgbActiveModal],
- imports: [
- HttpClientTestingModule,
- NgbTypeaheadModule,
- ReactiveFormsModule,
- RouterTestingModule,
- SharedModule,
- ToastrModule.forRoot()
- ]
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ params: of({ subsystem_nqn: 'nqn.2001-07.com.ceph:1' }),
+ queryParams: of({ group: 'group1' }),
+ snapshot: { params: { subsystem_nqn: 'nqn.2001-07.com.ceph:1' } },
+ parent: {
+ snapshot: { params: { subsystem_nqn: 'nqn.2001-07.com.ceph:1' } },
+ params: of({ subsystem_nqn: 'nqn.2001-07.com.ceph:1' })
+ }
+ }
+ }
+ ],
+ imports: [HttpClientTestingModule, RouterTestingModule, SharedModule, ToastrModule.forRoot()],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
fixture = TestBed.createComponent(NvmeofListenersFormComponent);
component = fixture.componentInstance;
- component.ngOnInit();
fixture.detectChanges();
});
-import _ from 'lodash';
import { Component, OnInit } from '@angular/core';
-import { UntypedFormControl, Validators } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
-import { GatewayGroup, ListenerRequest, 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 { Step } from 'carbon-components-angular';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { FinishedTask } from '~/app/shared/models/finished-task';
-import { Permission } from '~/app/shared/models/permissions';
-import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ListenerItem } from '~/app/shared/models/nvmeof';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { FormatterService } from '~/app/shared/services/formatter.service';
-import { CdValidators } from '~/app/shared/forms/cd-validators';
-import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
-import { HostService } from '~/app/shared/api/host.service';
-import { map } from 'rxjs/operators';
-import { forkJoin } from 'rxjs';
-import { Host } from '~/app/shared/models/host.interface';
+import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'cd-nvmeof-listeners-form',
standalone: false
})
export class NvmeofListenersFormComponent implements OnInit {
- action: string;
- permission: Permission;
- hostPermission: Permission;
- resource: string;
- pageURL: string;
- listenerForm: CdFormGroup;
- subsystemNQN: string;
- hosts: Array<object> = null;
- group: string;
+ group!: string;
+ subsystemNQN!: string;
+ isSubmitLoading = false;
+
+ steps: Step[] = [
+ {
+ label: $localize`Listeners`,
+ invalid: false
+ }
+ ];
+
+ title = $localize`Add Listener`;
+ description = $localize`Listeners determine where and how hosts can connect to the subsystem over the network.`;
constructor(
- public actionLabels: ActionLabelsI18n,
- private authStorageService: AuthStorageService,
- private taskWrapperService: TaskWrapperService,
private nvmeofService: NvmeofService,
- private hostService: HostService,
+ private taskWrapperService: TaskWrapperService,
private router: Router,
- private route: ActivatedRoute,
- public activeModal: NgbActiveModal,
- public formatterService: FormatterService,
- public dimlessBinaryPipe: DimlessBinaryPipe
- ) {
- this.permission = this.authStorageService.getPermissions().nvmeof;
- this.hostPermission = this.authStorageService.getPermissions().hosts;
- this.resource = $localize`Listener`;
- this.pageURL = 'block/nvmeof/subsystems';
- }
-
- filterHostsByLabel(allHosts: Host[], gwNodesLabel: string | string[]) {
- return allHosts.filter((host: Host) => {
- const hostLabels: string[] = host?.labels;
- if (typeof gwNodesLabel === 'string') {
- return hostLabels.includes(gwNodesLabel);
- }
- return hostLabels?.length === gwNodesLabel?.length && _.isEqual(hostLabels, gwNodesLabel);
- });
- }
-
- filterHostsByHostname(allHosts: Host[], gwNodes: string[]) {
- return allHosts.filter((host: Host) => gwNodes.includes(host.hostname));
- }
-
- getGwGroupPlacement(gwGroups: GatewayGroup[][]) {
- return (
- gwGroups?.[0]?.find((gwGroup: GatewayGroup) => gwGroup?.spec?.group === this.group)
- ?.placement || { hosts: [], label: [] }
- );
- }
-
- setHosts() {
- forkJoin({
- gwGroups: this.nvmeofService.listGatewayGroups(),
- allHosts: this.hostService.getAllHosts()
- })
- .pipe(
- map(({ gwGroups, allHosts }) => {
- const { hosts, label } = this.getGwGroupPlacement(gwGroups);
- if (hosts?.length) return this.filterHostsByHostname(allHosts, hosts);
- else if (label?.length) return this.filterHostsByLabel(allHosts, label);
- return [];
- })
- )
- .subscribe((nvmeofGwNodes: Host[]) => {
- this.hosts = nvmeofGwNodes.map((h) => ({ hostname: h.hostname, addr: h.addr }));
- });
- }
+ private route: ActivatedRoute
+ ) {}
ngOnInit() {
- this.createForm();
- this.action = this.actionLabels.CREATE;
- this.route.params.subscribe((params: { subsystem_nqn: string }) => {
- this.subsystemNQN = params?.subsystem_nqn;
- });
this.route.queryParams.subscribe((params) => {
this.group = params?.['group'];
});
- this.setHosts();
+ // subsystem_nqn can be in route.params (create/:subsystem_nqn/listener)
+ // or route.parent.params (subsystems/:subsystem_nqn > add/listener)
+ const params = this.route.snapshot.params;
+ const parentParams = this.route.parent?.snapshot.params;
+ this.subsystemNQN = params?.['subsystem_nqn'] || parentParams?.['subsystem_nqn'];
}
- createForm() {
- this.listenerForm = new CdFormGroup({
- host: new UntypedFormControl(null, {
- validators: [Validators.required]
- }),
- trsvcid: new UntypedFormControl(4420, [
- Validators.required,
- CdValidators.number(false),
- Validators.max(65535)
- ])
- });
- }
-
- buildRequest(): ListenerRequest {
- const host = this.listenerForm.getValue('host');
- let trsvcid = Number(this.listenerForm.getValue('trsvcid'));
- if (!trsvcid) trsvcid = 4420;
- const request: ListenerRequest = {
- gw_group: this.group,
- host_name: host.hostname,
- traddr: host.addr,
- trsvcid
- };
- return request;
- }
+ onSubmit(payload: { listeners: ListenerItem[] }) {
+ if (!payload.listeners || payload.listeners.length === 0) {
+ return;
+ }
+ this.isSubmitLoading = true;
+ const taskUrl = `nvmeof/listener/add`;
- onSubmit() {
- const component = this;
- const taskUrl: string = `nvmeof/listener/${URLVerbs.CREATE}`;
- const request = this.buildRequest();
this.taskWrapperService
.wrapTaskAroundCall({
task: new FinishedTask(taskUrl, {
nqn: this.subsystemNQN,
- host_name: request.host_name
+ count: payload.listeners.length
}),
- call: this.nvmeofService.createListener(this.subsystemNQN, request)
+ call: this.nvmeofService.createListeners(this.subsystemNQN, this.group, payload.listeners)
})
.subscribe({
- error() {
- component.listenerForm.setErrors({ cdSubmitButton: true });
+ error: () => {
+ this.isSubmitLoading = false;
},
complete: () => {
- this.router.navigate([this.pageURL, { outlets: { modal: null } }], {
+ this.isSubmitLoading = false;
+ this.router.navigate([{ outlets: { modal: null } }], {
+ relativeTo: this.route.parent,
queryParamsHandling: 'preserve'
});
}
[autoReload]="true"
forceIdentifier="true"
selectionType="single"
+ emptyStateTitle="No listener found."
+ i18n-emptyStateTitle
+ emptyStateMessage="No listeners found. Add listeners to define network endpoints for hosts"
+ i18n-emptyStateMessage
+ [emptyStateIcon]="iconType.emptySearch"
(updateSelection)="updateSelection($event)">
<div class="table-actions">
<cd-table-actions [permission]="permission"
import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
-import { Icons } from '~/app/shared/enum/icons.enum';
+import { Icons, ICON_TYPE } from '~/app/shared/enum/icons.enum';
import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { FinishedTask } from '~/app/shared/models/finished-task';
import { NvmeofListener } from '~/app/shared/models/nvmeof';
+import { Host } from '~/app/shared/models/host.interface';
import { Permission } from '~/app/shared/models/permissions';
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';
-const BASE_URL = 'block/nvmeof/subsystems';
-
@Component({
selector: 'cd-nvmeof-listeners-list',
templateUrl: './nvmeof-listeners-list.component.html',
@Input()
group: string;
- listenerColumns: any;
+ listenerColumns: CdTableColumn[];
tableActions: CdTableAction[];
selection = new CdTableSelection();
permission: Permission;
listeners: NvmeofListener[];
+ hasAvailableNodes = true;
+ iconType = ICON_TYPE;
constructor(
public actionLabels: ActionLabelsI18n,
];
this.tableActions = [
{
- name: this.actionLabels.CREATE,
+ name: this.actionLabels.ADD,
permission: 'create',
icon: Icons.add,
click: () =>
- this.router.navigate(
- [BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'listener'] } }],
- { queryParams: { group: this.group } }
- ),
- canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'listener'] } }], {
+ queryParams: { group: this.group },
+ relativeTo: this.route.parent
+ }),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+ disable: () =>
+ !this.hasAvailableNodes ? $localize`All available nodes already have listeners` : false
},
{
name: this.actionLabels.DELETE,
listener['full_addr'] = `${listener.traddr}:${listener.trsvcid}`;
return listener;
});
+ this.checkAvailableNodes();
});
}
+ checkAvailableNodes() {
+ if (!this.group) return;
+ this.nvmeofService.getHostsForGroup(this.group).subscribe({
+ next: (allHosts: Host[]) => {
+ const listenerHostNames = new Set((this.listeners || []).map((l) => l.host_name));
+ this.hasAvailableNodes = allHosts.some((h) => !listenerHostNames.has(h.hostname));
+ },
+ error: () => {
+ this.hasAvailableNodes = true;
+ }
+ });
+ }
+
deleteListenerModal() {
const listener = this.selection.first();
this.modalService.show(DeleteConfirmationModalComponent, {
validators: [Validators.required]
}),
image_size: new UntypedFormControl(null, {
- validators: [Validators.required],
- updateOn: 'blur'
+ validators: [Validators.required]
}),
nsCount: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
Validators.required,
return Math.random().toString(36).substring(2);
}
+ private normalizeImageSizeInput(value: string): string {
+ const input = (value || '').trim();
+ if (!input) {
+ return input;
+ }
+ // Accept plain numeric values as GiB (e.g. "45" => "45GiB").
+ return /^\d+(\.\d+)?$/.test(input) ? `${input}GiB` : input;
+ }
+
buildCreateRequest(
rbdImageSize: number,
nsCount: number,
let rbdImageSize: number = null;
if (image_size) {
- rbdImageSize = this.formatterService.toBytes(image_size);
+ const normalizedSize = this.normalizeImageSizeInput(image_size);
+ rbdImageSize = this.formatterService.toBytes(normalizedSize);
+ if (rbdImageSize === null) {
+ this.nsForm.get('image_size').setErrors({ invalid: true });
+ this.nsForm.setErrors({ cdSubmitButton: true });
+ return;
+ }
}
const subsystemNQN = this.nsForm.getValue('subsystem');
expect(component.namespaces.length).toEqual(2);
expect(component.namespaces[0].nsid).toEqual(1);
}));
+ it('should have table actions defined', () => {
+ component.ngOnInit();
+ expect(component.tableActions).toBeDefined();
+ expect(component.tableActions.length).toBeGreaterThan(0);
+ });
});
[fullWidth]="true">
<div cdsCol
[columnNumbers]="{sm: 4, md: 8}">
+ @if (!listenersOnly) {
<div cdsRow
class="form-heading">
<h3 class="cds--type-heading-03"
i18n>Subsystem details</h3>
- <p
- class="cds--type-label-02"
- i18n>Enter identifying and network details for this subsystem.</p>
+ <p class="cds--type-label-02"
+ i18n>Enter identifying and network details for this subsystem.</p>
</div>
+ }
+ @if (!listenersOnly) {
<div cdsRow
class="form-item">
<cds-text-label
cdValidate
#nqnRef="cdValidate"
type="text"
+ placeholder="nqn.2001-07.com.ceph:1722347201377"
+ id="nqn"
+ name="nqn"
formControlName="nqn"
[invalid]="nqnRef.isInvalid">
</cds-text-label>
[skeleton]="!group">
</cds-text-label>
</div>
+ }
+ <div cdsRow
+ class="form-item">
+ <cds-combo-box i18n
+ [invalid]="formGroup.get('listeners').invalid && (formGroup.get('listeners').dirty || formGroup.get('listeners').touched)"
+ [invalidText]="listenersInvalidText"
+ [label]="listenersLabel"
+ [helperText]="listenersHelperText"
+ [title]="listenersLabel"
+ [items]="hosts"
+ [type]="'multi'"
+ formControlName="listeners"
+ name="listeners">
+ <cds-dropdown-list></cds-dropdown-list>
+ </cds-combo-box>
+ @if (formGroup.get('listeners').value?.length) {
+ <div>
+ @for (listener of formGroup.get('listeners').value; track listener.content; let i = $index) {
+ <cds-tag-filter type="blue"
+ [title]="listener.content"
+ (close)="removeListener(i)">
+ {{ listener.content }}
+ </cds-tag-filter>
+ }
+ </div>
+ }
+ <ng-template #listenersLabel>
+ <span i18n>Listeners</span>
+ </ng-template>
+ <ng-template #listenersHelperText>
+ <span i18n>Select listeners for this subsystem.</span>
+ </ng-template>
+ <ng-template #listenersInvalidText>
+ <span i18n>This field is required.</span>
+ </ng-template>
+ </div>
</div>
</div>
</form>
+@if (!listenersOnly) {
<ng-template #nqnInvalidTemplate>
@for (err of formGroup.get('nqn').errors | keyvalue; track err.key) {
<span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
}
</ng-template>
+}
+// Custom SCSS removed as selectionTemplate is now supported.
import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1.component';
import { FormHelper } from '~/testing/unit-test-helper';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { GridModule, InputModule } from 'carbon-components-angular';
+import { ComboBoxModule, GridModule, InputModule } from 'carbon-components-angular';
+
+import { of } from 'rxjs';
describe('NvmeofSubsystemsStepOneComponent', () => {
let component: NvmeofSubsystemsStepOneComponent;
let fixture: ComponentFixture<NvmeofSubsystemsStepOneComponent>;
+
let nvmeofService: NvmeofService;
+
let form: CdFormGroup;
let formHelper: FormHelper;
const mockGroupName = 'default';
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [NvmeofSubsystemsStepOneComponent],
- providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
- NgbTypeaheadModule,
+ SharedModule,
ReactiveFormsModule,
RouterTestingModule,
- SharedModule,
+ NgbTypeaheadModule,
InputModule,
GridModule,
+ ComboBoxModule,
ToastrModule.forRoot()
- ]
+ ],
+ providers: [NgbActiveModal]
}).compileComponents();
+ });
+ beforeEach(() => {
fixture = TestBed.createComponent(NvmeofSubsystemsStepOneComponent);
component = fixture.componentInstance;
+
+ nvmeofService = TestBed.inject(NvmeofService);
+ spyOn(nvmeofService, 'getHostsForGroup').and.returnValue(of([]));
component.ngOnInit();
form = component.formGroup;
formHelper = new FormHelper(form);
import { Component, Input, OnInit } from '@angular/core';
+import { forkJoin, of } from 'rxjs';
import { UntypedFormControl, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
+import { Host } from '~/app/shared/models/host.interface';
+import { ListenerItem } from '~/app/shared/models/nvmeof';
@Component({
selector: 'cd-nvmeof-subsystem-step-one',
})
export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep {
@Input() group!: string;
+ @Input() subsystemNQN: string;
+ @Input() listenersOnly = false;
formGroup: CdFormGroup;
action: string;
pageURL: string;
maxLength: $localize`An NQN may not be more than 223 bytes in length.`
};
+ hosts: ListenerItem[] = [];
+
constructor(
public actionLabels: ActionLabelsI18n,
public activeModal: NgbActiveModal,
ngOnInit() {
this.createForm();
+ this.setHosts();
+ }
+
+ setHosts() {
+ const hosts$ = this.nvmeofService.getHostsForGroup(this.group);
+ const listeners$ =
+ this.listenersOnly && this.subsystemNQN
+ ? this.nvmeofService.listListeners(this.subsystemNQN, this.group)
+ : of(null);
+
+ forkJoin([hosts$, listeners$]).subscribe(
+ ([nvmeofGwNodes, existingListeners]: [Host[], any]) => {
+ const listeners = Array.isArray(existingListeners)
+ ? existingListeners
+ : existingListeners?.listeners || [];
+ const consumedHosts = new Set(listeners.map((l: any) => l.host_name));
+ this.hosts = nvmeofGwNodes
+ .map((h) => ({ content: h.hostname, addr: h.addr }))
+ .filter((h) => !consumedHosts.has(h.content));
+ }
+ );
}
createForm() {
- this.formGroup = new CdFormGroup({
- nqn: new UntypedFormControl(this.DEFAULT_NQN, {
- validators: [
- this.customNQNValidator,
- Validators.required,
- CdValidators.custom(
- 'maxLength',
- (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223
- )
- ],
- asyncValidators: [
- CdValidators.unique(
- this.nvmeofService.isSubsystemPresent,
- this.nvmeofService,
- null,
- null,
- this.group
- )
- ]
- })
- });
+ if (this.listenersOnly) {
+ this.formGroup = new CdFormGroup({
+ listeners: new UntypedFormControl([])
+ });
+ } else {
+ this.formGroup = new CdFormGroup({
+ nqn: new UntypedFormControl(this.DEFAULT_NQN, {
+ validators: [
+ this.customNQNValidator,
+ Validators.required,
+ CdValidators.custom(
+ 'maxLength',
+ (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223
+ )
+ ],
+ asyncValidators: [
+ CdValidators.unique(
+ this.nvmeofService.isSubsystemPresent,
+ this.nvmeofService,
+ null,
+ null,
+ this.group
+ )
+ ]
+ }),
+ listeners: new UntypedFormControl([])
+ });
+ }
+ }
+
+ removeListener(index: number) {
+ const listeners = this.formGroup.get('listeners').value;
+ listeners.splice(index, 1);
+ this.formGroup.get('listeners').setValue([...listeners]);
}
}
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
} from './nvmeof-subsystems-form.component';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
-import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
+import {
+ ComboBoxModule,
+ GridModule,
+ InputModule,
+ RadioModule,
+ TagModule
+} from 'carbon-components-angular';
import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
import { HOST_TYPE } from '~/app/shared/models/nvmeof';
import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component';
gw_group: mockGroupName,
subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=',
addedHosts: [],
- hostType: HOST_TYPE.ALL
+ hostType: HOST_TYPE.ALL,
+ listeners: []
};
beforeEach(async () => {
NvmeofSubsystemsStepThreeComponent,
NvmeofSubsystemsStepTwoComponent
],
- providers: [NgbActiveModal],
+ providers: [
+ NgbActiveModal,
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParams: of({ group: mockGroupName })
+ }
+ }
+ ],
imports: [
HttpClientTestingModule,
NgbTypeaheadModule,
GridModule,
RadioModule,
TagModule,
- ToastrModule.forRoot()
+ ToastrModule.forRoot(),
+ ComboBoxModule
]
}).compileComponents();
component = fixture.componentInstance;
component.ngOnInit();
fixture.detectChanges();
- component.group = mockGroupName;
});
it('should create', () => {
gw_group: mockGroupName,
addedHosts: [],
hostType: HOST_TYPE.ALL,
- subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
+ subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=',
+ listeners: []
};
component.group = mockGroupName;
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
-import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { ActivatedRoute, Router } from '@angular/router';
import { Step } from 'carbon-components-angular';
import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service';
import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
-import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { HOST_TYPE, ListenerItem } from '~/app/shared/models/nvmeof';
import { from, Observable, of } from 'rxjs';
import { NotificationService } from '~/app/shared/services/notification.service';
import { NotificationType } from '~/app/shared/enum/notification-type.enum';
subsystemDchapKey: string;
addedHosts: string[];
hostType: string;
+ listeners: ListenerItem[];
};
type StepResult = { step: string; success: boolean; error?: string };
standalone: false
})
export class NvmeofSubsystemsFormComponent implements OnInit {
- subsystemForm: CdFormGroup;
action: string;
group: string;
steps: Step[] = [
.subscribe({
next: () => {
stepResults.push({ step: this.steps[0].label, success: true });
- this.runSequentialSteps(
- [
- {
- step: this.steps[1].label,
- call: () =>
- this.nvmeofService.addInitiators(`${payload.nqn}.${this.group}`, initiatorRequest)
- }
- ],
- stepResults
- ).subscribe({
+ const sequentialSteps: { step: string; call: () => Observable<any> }[] = [];
+
+ if (payload.listeners && payload.listeners.length > 0) {
+ sequentialSteps.push({
+ step: $localize`Listeners`,
+ call: () =>
+ this.nvmeofService.createListeners(
+ `${payload.nqn}.${this.group}`,
+ this.group,
+ payload.listeners
+ )
+ });
+ }
+
+ sequentialSteps.push({
+ step: this.steps[1].label,
+ call: () =>
+ this.nvmeofService.addInitiators(`${payload.nqn}.${this.group}`, initiatorRequest)
+ });
+
+ this.runSequentialSteps(sequentialSteps, stepResults).subscribe({
complete: () => this.showFinalNotification(stepResults)
});
},
-import {
- Component,
- OnDestroy,
- OnInit,
- TemplateRef,
- ViewChild,
- ChangeDetectorRef
-} from '@angular/core';
+import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
private router: Router,
private route: ActivatedRoute,
private modalService: ModalCdsService,
- private taskWrapper: TaskWrapperService,
- private cdRef: ChangeDetectorRef
+ private taskWrapper: TaskWrapperService
) {
super();
this.permissions = this.authStorageService.getPermissions();
}),
tap((subs) => {
this.subsystems = subs;
- this.expandPendingSubsystem();
}),
takeUntil(this.destroy$)
);
this.context?.error?.(error);
}
- private expandPendingSubsystem() {
- if (!this.pendingNqn) return;
- const match = this.subsystems.find((s) => s.nqn === this.pendingNqn);
- if (match && this.table) {
- setTimeout(() => {
- this.table.expanded = match;
- this.table.toggleExpandRow();
- this.cdRef.detectChanges();
- });
- }
- this.pendingNqn = null;
- }
-
private enrichSubsystemWithInitiators(sub: NvmeofSubsystem) {
return this.nvmeofService.getInitiators(sub.nqn, this.group).pipe(
catchError(() => of([])),
enable_ha: true,
initiators: '*',
gw_group: mockGroupName,
- dhchap_key: ''
+ dhchap_key: null
};
service.createSubsystem(request).subscribe();
const req = httpTesting.expectOne(`${API_PATH}/subsystem`);
const mockNsid = '1';
it('should call listNamespaces', () => {
service.listNamespaces(mockGroupName).subscribe();
- const req = httpTesting.expectOne(
- `${API_PATH}/subsystem/*/namespace?gw_group=${mockGroupName}`
- );
+ const req = httpTesting.expectOne(`${API_PATH}/subsystem/*/namespace?gw_group=${mockGroupName}`);
expect(req.request.method).toBe('GET');
});
it('should call getNamespace', () => {
expect(req.request.method).toBe('DELETE');
});
});
+
+ describe('getHostsForGroup', () => {
+ const allHosts = [
+ { hostname: 'host1', labels: ['nvmeof'], status: '' },
+ { hostname: 'host2', labels: ['storage'], status: '' },
+ { hostname: 'host3', labels: ['nvmeof', 'storage'], status: '' }
+ ];
+
+ it('should filter hosts by direct host placement', (done) => {
+ const mockGroups = [
+ [{ spec: { group: 'default' }, placement: { hosts: ['host1', 'host3'], label: [] } }]
+ ];
+ mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+ service.getHostsForGroup('default').subscribe((hosts: any[]) => {
+ expect(hosts.length).toBe(2);
+ expect(hosts.map((h: any) => h.hostname)).toEqual(['host1', 'host3']);
+ done();
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ req.flush(mockGroups);
+ });
+
+ it('should filter hosts by string label placement', (done) => {
+ const mockGroups = [
+ [{ spec: { group: 'default' }, placement: { hosts: [], label: 'nvmeof' } }]
+ ];
+ mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+ service.getHostsForGroup('default').subscribe((hosts: any[]) => {
+ expect(hosts.length).toBe(2);
+ expect(hosts.map((h: any) => h.hostname)).toEqual(['host1', 'host3']);
+ done();
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ req.flush(mockGroups);
+ });
+
+ it('should filter hosts by array label placement', (done) => {
+ const mockGroups = [
+ [{ spec: { group: 'default' }, placement: { hosts: [], label: ['nvmeof', 'storage'] } }]
+ ];
+ mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+ service.getHostsForGroup('default').subscribe((hosts: any[]) => {
+ expect(hosts.length).toBe(1);
+ expect(hosts[0].hostname).toBe('host3');
+ done();
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ req.flush(mockGroups);
+ });
+
+ it('should return empty array when group not found', (done) => {
+ const mockGroups = [
+ [{ spec: { group: 'other' }, placement: { hosts: ['host1'], label: [] } }]
+ ];
+ mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+ service.getHostsForGroup('non-existent').subscribe((hosts: any[]) => {
+ expect(hosts.length).toBe(0);
+ done();
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ req.flush(mockGroups);
+ });
+
+ it('should return empty array when placement has no hosts or labels', (done) => {
+ const mockGroups = [[{ spec: { group: 'default' }, placement: { hosts: [], label: [] } }]];
+ mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+ service.getHostsForGroup('default').subscribe((hosts: any[]) => {
+ expect(hosts.length).toBe(0);
+ done();
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ req.flush(mockGroups);
+ });
+ });
+
+ describe('createListeners', () => {
+ it('should call createListener for each listener in the array', () => {
+ const listeners = [
+ { content: 'ceph-node-01', addr: '192.168.1.1' },
+ { content: 'ceph-node-02', addr: '192.168.1.2' }
+ ];
+
+ service.createListeners('nqn.test', 'default', listeners).subscribe();
+
+ const reqs = httpTesting.match(`${API_PATH}/subsystem/nqn.test/listener`);
+ expect(reqs.length).toBe(2);
+ expect(reqs[0].request.method).toBe('POST');
+ expect(reqs[0].request.body).toEqual({
+ gw_group: 'default',
+ host_name: 'ceph-node-01',
+ traddr: '192.168.1.1',
+ trsvcid: 4420
+ });
+ expect(reqs[1].request.body).toEqual({
+ gw_group: 'default',
+ host_name: 'ceph-node-02',
+ traddr: '192.168.1.2',
+ trsvcid: 4420
+ });
+
+ reqs.forEach((req) => req.flush({}, { status: 200, statusText: 'OK' }));
+ });
+
+ it('should call createListener for a single listener', () => {
+ const listeners = [{ content: 'ceph-node-01', addr: '192.168.1.1' }];
+
+ service.createListeners('nqn.test', 'group1', listeners).subscribe();
+
+ const reqs = httpTesting.match(`${API_PATH}/subsystem/nqn.test/listener`);
+ expect(reqs.length).toBe(1);
+ expect(reqs[0].request.body).toEqual({
+ gw_group: 'group1',
+ host_name: 'ceph-node-01',
+ traddr: '192.168.1.1',
+ trsvcid: 4420
+ });
+
+ reqs[0].flush({}, { status: 200, statusText: 'OK' });
+ });
+ });
});
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 { ListenerItem } from '../models/nvmeof';
import { HostService } from './host.service';
import { OrchestratorService } from './orchestrator.service';
import { HostStatus } from '../enum/host-status.enum';
rbd_pool: string;
rbd_image_size?: number;
no_auto_visible?: boolean;
- create_image: boolean;
block_size?: number;
+ create_image: boolean;
};
export type NamespaceUpdateRequest = NvmeofRequest & {
});
}
+ getHostsForGroup(groupName: string): Observable<Host[]> {
+ return forkJoin({
+ gwGroups: this.listGatewayGroups(),
+ allHosts: this.hostService.getAllHosts()
+ }).pipe(
+ map(({ gwGroups, allHosts }) => {
+ const group = gwGroups?.[0]?.find(
+ (gwGroup: CephServiceSpec) => gwGroup?.spec?.group === groupName
+ );
+ const placement = group?.placement || { hosts: [], label: [] };
+ const { hosts, label } = placement;
+
+ if (hosts?.length) {
+ return allHosts.filter((host: Host) => hosts.includes(host.hostname));
+ } else if (label?.length) {
+ if (typeof label === 'string') {
+ return allHosts.filter((host: Host) => host?.labels?.includes(label));
+ }
+ return allHosts.filter(
+ (host: Host) =>
+ host?.labels?.length === label?.length &&
+ _.isEqual([...host.labels].sort(), [...label].sort())
+ );
+ }
+ return [];
+ })
+ );
+ }
+
// formats the gateway groups to be consumed for combobox item
formatGwGroupsList(
data: CephServiceSpec[][],
});
}
+ addNamespaceInitiators(nsid: number | string, request: NamespaceInitiatorRequest) {
+ return this.http.post(
+ `${UI_API_PATH}/subsystem/${request.subsystem_nqn}/namespace/${nsid}/host`,
+ request,
+ {
+ observe: 'response'
+ }
+ );
+ }
+
updateHostKey(subsystemNQN: string, request: InitiatorRequest) {
return this.http.put(
`${API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/change_key`,
);
}
- addNamespaceInitiators(nsid: string, request: NamespaceInitiatorRequest) {
- return this.http.post(`${UI_API_PATH}/namespace/${nsid}/host`, request, {
- observe: 'response'
- });
- }
-
removeInitiators(subsystemNQN: string, request: InitiatorRequest) {
return this.http.delete(
`${UI_API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/${request.gw_group}`,
});
}
+ createListeners(subsystemNQN: string, gwGroup: string, listeners: ListenerItem[]) {
+ const listenerCalls = listeners.map((listener: ListenerItem) =>
+ this.createListener(subsystemNQN, {
+ gw_group: gwGroup,
+ host_name: listener.content,
+ traddr: listener.addr,
+ trsvcid: 4420
+ })
+ );
+ return forkJoin(listenerCalls);
+ }
+
deleteListener(
subsystemNQN: string,
group: string,
}
);
}
- listSubsystemNamespaces(subsystemNQN: string) {
- return this.http.get<NvmeofSubsystemNamespace[]>(
- `${API_PATH}/subsystem/${subsystemNQN}/namespace`
- );
- }
// Namespaces
listNamespaces(group: string, subsystemNQN: string = '*') {
@else {
<!-- Wide Tearsheet -->
<cds-modal
- size="lg"
+ [size]="size"
[open]="isOpen"
(overlaySelected)="closeTearsheet()">
<!-- Tearsheet Header -->
flex-direction: column;
}
+:host ::ng-deep .cds--modal-container.cds--modal-container--sm {
+ inset-block-start: 88px;
+ block-size: 50vh;
+ max-block-size: 50vh;
+ inset-inline-start: 35%;
+ inset-inline-end: 35%;
+ inline-size: auto;
+ max-inline-size: none;
+}
+
// FULL TEARSHEET
.tearsheet--full {
height: 100%;
@Input() steps!: Array<Step>;
@Input() description!: string;
@Input() type: 'full' | 'wide' = 'wide';
+ @Input() size: 'xs' | 'sm' | 'md' | 'lg' = 'lg';
@Input() submitButtonLabel: string = $localize`Create`;
@Input() submitButtonLoadingLabel: string = $localize`Creating`;
@Input() isSubmitLoading: boolean = true;
rw_mbytes_per_second: number | string;
r_mbytes_per_second: number | string;
w_mbytes_per_second: number | string;
- ns_subsystem_nqn?: string; // Field from JSON
- subsystem_nqn?: string; // Keep for compatibility if needed, but JSON has ns_subsystem_nqn
+ subsystem_nqn?: string; // Field from JSON (mapped from ns_subsystem_nqn if needed)
}
export interface NvmeofGatewayGroup extends CephServiceSpec {
SPECIFIC: 'specific'
};
+export interface ListenerItem {
+ content: string;
+ addr: string;
+}
+
/**
* Determines the authentication status of a subsystem based on PSK and initiators.
* Can be reused across subsystem pages.
'nvmeof/listener/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.nvmeofListener(metadata)
),
+ 'nvmeof/listener/add': this.newTaskMessage(this.commonOperations.add, (metadata) =>
+ this.nvmeofListenerPlural(metadata)
+ ),
'nvmeof/listener/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.nvmeofListener(metadata)
),
return $localize`hosts to gateway group '${metadata.group_name}'`;
}
nvmeofListener(metadata: any) {
- return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`;
+ return $localize`listener '${metadata.host_name}' for subsystem ${metadata.nqn}`;
+ }
+
+ nvmeofListenerPlural(metadata: { count: number; nqn: string }) {
+ return $localize`${this.pluralize('listener', metadata.count)} to subsystem ${metadata.nqn}`;
}
nvmeofNamespace(metadata: { nqn: string; nsCount?: number; nsid?: string }) {