1 import { Component, OnDestroy, OnInit } from '@angular/core';
2 import { UntypedFormControl, Validators } from '@angular/forms';
3 import { ActivatedRoute, Router } from '@angular/router';
5 NamespaceCreateRequest,
6 NamespaceInitiatorRequest,
7 NamespaceUpdateRequest,
9 } from '~/app/shared/api/nvmeof.service';
10 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
11 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
12 import { FinishedTask } from '~/app/shared/models/finished-task';
15 NvmeofSubsystemInitiator,
16 NvmeofSubsystemNamespace,
17 NvmeofNamespaceListResponse,
18 NvmeofInitiatorCandidate,
22 } from '~/app/shared/models/nvmeof';
23 import { Permission } from '~/app/shared/models/permissions';
24 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
25 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
26 import { Pool } from '../../pool/pool';
27 import { PoolService } from '~/app/shared/api/pool.service';
28 import { RbdPool, RbdImage } from '~/app/shared/api/rbd.model';
29 import { RbdService } from '~/app/shared/api/rbd.service';
30 import { FormatterService } from '~/app/shared/services/formatter.service';
31 import { forkJoin, Observable, of, Subject } from 'rxjs';
32 import { filter, switchMap, takeUntil, tap } from 'rxjs/operators';
33 import { CdValidators } from '~/app/shared/forms/cd-validators';
34 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
35 import { HttpResponse } from '@angular/common/http';
38 selector: 'cd-nvmeof-namespaces-form',
39 templateUrl: './nvmeof-namespaces-form.component.html',
40 styleUrls: ['./nvmeof-namespaces-form.component.scss'],
43 export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
45 permission: Permission;
46 poolPermission: Permission;
49 edit: boolean = false;
52 subsystems?: NvmeofSubsystem[];
53 rbdPools: Pool[] | null = null;
54 rbdImages: RbdImage[] = [];
55 initiatorCandidates: NvmeofInitiatorCandidate[] = [];
57 // Stores all RBD images fetched for the selected pool
58 private allRbdImages: RbdImage[] = [];
59 // Maps pool name to a Set of used image names for O(1) lookup
60 private usedRbdImages: Map<string, Set<string>> = new Map();
61 private lastSubsystemNqn: string;
64 currentBytes: number = 0;
66 MAX_NAMESPACE_CREATE: number = 5;
67 MIN_NAMESPACE_CREATE: number = 1;
68 private destroy$ = new Subject<void>();
69 INVALID_TEXTS: Record<string, string> = {
70 required: $localize`This field is required.`,
71 min: $localize`The namespace count should be between 1 and 5.`,
72 max: $localize`The namespace count should be between 1 and 5.`,
73 minSize: $localize`Enter a value larger than previous. A block device image can be expanded but not reduced.`,
74 rbdImageName: $localize`Image name contains invalid characters.`
78 public actionLabels: ActionLabelsI18n,
79 private authStorageService: AuthStorageService,
80 private taskWrapperService: TaskWrapperService,
81 private nvmeofService: NvmeofService,
82 private poolService: PoolService,
83 private rbdService: RbdService,
84 private router: Router,
85 private route: ActivatedRoute,
86 public formatterService: FormatterService,
87 public dimlessBinaryPipe: DimlessBinaryPipe
89 this.permission = this.authStorageService.getPermissions().nvmeof;
90 this.poolPermission = this.authStorageService.getPermissions().pool;
91 this.resource = $localize`Namespace`;
92 this.pageURL = 'block/nvmeof/gateways';
97 this.destroy$.complete();
101 this.route.queryParams.subscribe((params) => {
102 this.group = params?.['group'];
103 if (params?.['subsystem_nqn']) {
104 this.subsystemNQN = params?.['subsystem_nqn'];
109 this.action = this.actionLabels.CREATE;
110 this.route.params.subscribe((params: { subsystem_nqn: string; nsid: string }) => {
111 this.subsystemNQN = params.subsystem_nqn;
112 this.nsid = params?.nsid;
118 this.action = this.actionLabels.EDIT;
120 .getNamespace(this.subsystemNQN, this.nsid, this.group)
121 .subscribe((res: NvmeofSubsystemNamespace) => {
123 typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
124 this.nsForm.get(NsFormField.POOL).setValue(res.rbd_pool_name);
126 .get(NsFormField.IMAGE_SIZE)
127 .setValue(this.dimlessBinaryPipe.transform(res.rbd_image_size));
128 this.nsForm.get(NsFormField.IMAGE_SIZE).addValidators(Validators.required);
129 this.nsForm.get(NsFormField.POOL).disable();
130 this.nsForm.get(NsFormField.SUBSYSTEM).disable();
131 this.nsForm.get(NsFormField.SUBSYSTEM).setValue(this.subsystemNQN);
136 this.poolService.getList().subscribe((resp: Pool[]) => {
137 this.rbdPools = resp.filter(this.rbdService.isRBDPool);
139 this.route.queryParams
141 filter((params) => params?.['group']),
143 this.group = params['group'];
144 this.fetchUsedImages();
146 switchMap(() => this.nvmeofService.listSubsystems(this.group))
148 .subscribe((subsystems: NvmeofSubsystem[]) => {
149 this.subsystems = subsystems;
150 if (this.subsystemNQN) {
151 const selectedSubsystem = this.subsystems.find((s) => s.nqn === this.subsystemNQN);
152 if (selectedSubsystem) {
153 this.nsForm.get(NsFormField.SUBSYSTEM).setValue(selectedSubsystem.nqn);
161 if (this.router.url.includes('subsystems/(modal:edit')) {
164 this.initForCreate();
166 const subsystemControl = this.nsForm.get(NsFormField.SUBSYSTEM);
167 if (subsystemControl) {
168 subsystemControl.valueChanges.subscribe((nqn: string) => {
169 this.onSubsystemChange(nqn);
174 onPoolChange(): void {
175 const pool = this.nsForm.getValue(NsFormField.POOL);
179 .list({ pool_name: pool, offset: '0', limit: '-1' })
180 .subscribe((pools: RbdPool[]) => {
181 const selectedPool = pools.find((p) => p.pool_name === pool);
182 this.allRbdImages = selectedPool?.value ?? [];
185 const imageControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME);
186 const currentImage = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
187 if (currentImage && !this.rbdImages.some((img) => img.name === currentImage)) {
188 imageControl.setValue(null);
190 imageControl.markAsUntouched();
191 imageControl.markAsPristine();
195 fetchUsedImages(): void {
196 if (!this.group) return;
199 .listNamespaces(this.group)
200 .subscribe((response: NvmeofNamespaceListResponse) => {
201 const namespaces: NvmeofSubsystemNamespace[] = Array.isArray(response)
203 : response?.namespaces ?? [];
204 this.usedRbdImages = namespaces.reduce((map, ns) => {
205 if (!map.has(ns.rbd_pool_name)) {
206 map.set(ns.rbd_pool_name, new Set<string>());
208 map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name);
210 }, new Map<string, Set<string>>());
215 onSubsystemChange(nqn: string): void {
216 if (!nqn || nqn === this.lastSubsystemNqn) return;
217 this.lastSubsystemNqn = nqn;
219 .getInitiators(nqn, this.group)
220 .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
221 const initiators = Array.isArray(response) ? response : response?.hosts || [];
222 this.initiatorCandidates = initiators.map((initiator) => ({
223 content: initiator.nqn,
229 onInitiatorSelection(event: NvmeofInitiatorCandidate[]) {
230 // Carbon ComboBox (selected) emits the full array of selected items
231 const selectedInitiators = Array.isArray(event) ? event.map((e) => e.content) : [];
233 .get(NsFormField.INITIATORS)
234 .setValue(selectedInitiators.length > 0 ? selectedInitiators : null);
235 this.nsForm.get(NsFormField.INITIATORS).markAsDirty();
236 this.nsForm.get(NsFormField.INITIATORS).markAsTouched();
239 private filterImages(): void {
240 const pool = this.nsForm.getValue(NsFormField.POOL);
245 const usedInPool = this.usedRbdImages.get(pool);
246 this.rbdImages = usedInPool
247 ? this.allRbdImages.filter((img) => !usedInPool.has(img.name))
248 : [...this.allRbdImages];
252 this.nsForm = new CdFormGroup({
253 [NsFormField.POOL]: new UntypedFormControl('', {
254 validators: [Validators.required]
256 [NsFormField.SUBSYSTEM]: new UntypedFormControl('', {
257 validators: [Validators.required]
259 [NsFormField.IMAGE_SIZE]: new UntypedFormControl(null, {
262 CdValidators.custom('minSize', (value: any) => {
263 if (value !== null && value !== undefined && value !== '') {
264 const bytes = this.formatterService.toBytes(value);
266 (!this.edit && bytes <= 0) ||
267 (this.edit && this.currentBytes && bytes <= this.currentBytes)
269 return { minSize: true };
277 [NsFormField.NS_COUNT]: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
279 Validators.max(this.MAX_NAMESPACE_CREATE),
280 Validators.min(this.MIN_NAMESPACE_CREATE)
282 [NsFormField.RBD_IMAGE_CREATION]: new UntypedFormControl(
283 RbdImageCreation.GATEWAY_PROVISIONED
286 [NsFormField.RBD_IMAGE_NAME]: new UntypedFormControl(null, [
287 CdValidators.custom('rbdImageName', (value: any) => {
288 if (!value) return null;
289 return /^[^@/]+$/.test(value) ? null : { rbdImageName: true };
292 [NsFormField.NAMESPACE_SIZE]: new UntypedFormControl(null, [Validators.min(0)]), // sent as block_size in create request
293 [NsFormField.HOST_ACCESS]: new UntypedFormControl(HOST_TYPE.ALL), // drives no_auto_visible in create request
294 [NsFormField.INITIATORS]: new UntypedFormControl([]) // sent via addNamespaceInitiators API
298 .get(NsFormField.POOL)
299 .valueChanges.pipe(takeUntil(this.destroy$))
305 .get(NsFormField.NS_COUNT)
306 .valueChanges.pipe(takeUntil(this.destroy$))
307 .subscribe((count: number) => {
309 const creationControl = this.nsForm.get(NsFormField.RBD_IMAGE_CREATION);
310 if (creationControl.value === RbdImageCreation.EXTERNALLY_MANAGED) {
311 creationControl.setValue(RbdImageCreation.GATEWAY_PROVISIONED);
317 .get(NsFormField.RBD_IMAGE_CREATION)
318 .valueChanges.pipe(takeUntil(this.destroy$))
319 .subscribe((mode: string) => {
320 const nameControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME);
321 const countControl = this.nsForm.get(NsFormField.NS_COUNT);
322 const imageSizeControl = this.nsForm.get(NsFormField.IMAGE_SIZE);
324 if (mode === RbdImageCreation.EXTERNALLY_MANAGED) {
325 countControl.setValue(1);
326 countControl.disable();
328 nameControl.addValidators(Validators.required);
329 imageSizeControl.disable();
330 imageSizeControl.removeValidators(Validators.required);
332 countControl.enable();
333 nameControl.removeValidators(Validators.required);
334 imageSizeControl.enable();
335 imageSizeControl.addValidators(Validators.required);
337 nameControl.updateValueAndValidity();
338 imageSizeControl.updateValueAndValidity();
342 .get(NsFormField.HOST_ACCESS)
343 .valueChanges.pipe(takeUntil(this.destroy$))
344 .subscribe((mode: string) => {
345 const initiatorsControl = this.nsForm.get(NsFormField.INITIATORS);
346 if (mode === HOST_TYPE.SPECIFIC) {
347 initiatorsControl.addValidators(Validators.required);
349 initiatorsControl.removeValidators(Validators.required);
350 initiatorsControl.setValue([]);
351 this.initiatorCandidates.forEach((i) => (i.selected = false));
353 initiatorsControl.updateValueAndValidity();
357 buildUpdateRequest(rbdImageSize: number): Observable<HttpResponse<Object>> {
358 const request: NamespaceUpdateRequest = {
359 gw_group: this.group,
360 rbd_image_size: rbdImageSize
362 return this.nvmeofService.updateNamespace(
365 request as NamespaceUpdateRequest
370 return Math.random().toString(36).substring(2);
374 rbdImageSize: number,
376 noAutoVisible: boolean
377 ): Observable<HttpResponse<Object>>[] {
378 const pool = this.nsForm.getValue(NsFormField.POOL);
379 const requests: Observable<HttpResponse<Object>>[] = [];
380 const creationMode = this.nsForm.getValue(NsFormField.RBD_IMAGE_CREATION);
381 const isGatewayProvisioned = creationMode === RbdImageCreation.GATEWAY_PROVISIONED;
383 const loopCount = isGatewayProvisioned ? nsCount : 1;
385 for (let i = 1; i <= loopCount; i++) {
386 const request: NamespaceCreateRequest = {
387 gw_group: this.group,
389 create_image: isGatewayProvisioned,
390 no_auto_visible: noAutoVisible
393 const blockSize = this.nsForm.getValue(NsFormField.NAMESPACE_SIZE);
395 request.block_size = blockSize;
398 if (isGatewayProvisioned) {
399 request.rbd_image_name = `nvme_${pool}_${this.group}_${this.randomString()}`;
401 request['rbd_image_size'] = rbdImageSize;
405 const rbdImageName = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
407 request['rbd_image_name'] = loopCount > 1 ? `${rbdImageName}-${i}` : rbdImageName;
410 const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM) || this.subsystemNQN;
411 requests.push(this.nvmeofService.createNamespace(subsystemNQN, request));
418 if (this.nsForm.invalid) {
419 this.nsForm.setErrors({ cdSubmitButton: true });
420 this.nsForm.markAllAsTouched();
424 const component = this;
425 const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`;
426 const image_size = this.nsForm.getValue(NsFormField.IMAGE_SIZE);
427 const nsCount = this.nsForm.getValue(NsFormField.NS_COUNT);
428 const hostAccess = this.nsForm.getValue(NsFormField.HOST_ACCESS);
429 const selectedHosts: string[] = this.nsForm.getValue(NsFormField.INITIATORS) || [];
430 const noAutoVisible = hostAccess === HOST_TYPE.SPECIFIC;
431 let action: Observable<any>;
432 let rbdImageSize: number = null;
435 rbdImageSize = this.formatterService.toBytes(image_size);
439 action = this.taskWrapperService.wrapTaskAroundCall({
440 task: new FinishedTask(taskUrl, {
441 nqn: this.subsystemNQN,
444 call: this.buildUpdateRequest(rbdImageSize)
447 const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM);
449 // Step 1: Create namespaces
450 // Step 2: If specific hosts selected, chain addNamespaceInitiators calls
451 const createObs = forkJoin(this.buildCreateRequest(rbdImageSize, nsCount, noAutoVisible));
453 const combinedObs = createObs.pipe(
454 switchMap((responses: HttpResponse<Object>[]) => {
455 if (noAutoVisible && selectedHosts.length > 0) {
456 const initiatorObs: Observable<any>[] = [];
458 responses.forEach((res) => {
459 const body: any = res.body;
460 if (body && body.nsid) {
461 selectedHosts.forEach((host: string) => {
462 const req: NamespaceInitiatorRequest = {
463 gw_group: this.group,
464 subsystem_nqn: subsystemNQN || this.subsystemNQN,
467 initiatorObs.push(this.nvmeofService.addNamespaceInitiators(body.nsid, req));
472 if (initiatorObs.length > 0) {
473 return forkJoin(initiatorObs);
476 return of(responses);
480 action = this.taskWrapperService.wrapTaskAroundCall({
481 task: new FinishedTask(taskUrl, {
491 component.nsForm.setErrors({ cdSubmitButton: true });
494 this.router.navigate([this.pageURL], {
495 queryParams: { group: this.group, tab: 'namespace' }