1 import { Component, OnInit } from '@angular/core';
2 import { UntypedFormControl, Validators } from '@angular/forms';
3 import { ActivatedRoute, Params, Router } from '@angular/router';
5 NamespaceCreateRequest,
6 NamespaceInitiatorRequest,
8 } from '~/app/shared/api/nvmeof.service';
9 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
10 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
11 import { FinishedTask } from '~/app/shared/models/finished-task';
14 NvmeofSubsystemInitiator,
15 NvmeofSubsystemNamespace
16 } from '~/app/shared/models/nvmeof';
17 import { Permission } from '~/app/shared/models/permissions';
18 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
19 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
20 import { Pool } from '../../pool/pool';
21 import { PoolService } from '~/app/shared/api/pool.service';
22 import { RbdService } from '~/app/shared/api/rbd.service';
23 import { FormatterService } from '~/app/shared/services/formatter.service';
24 import { forkJoin, Observable, of } from 'rxjs';
25 import { switchMap } from 'rxjs/operators';
26 import { CdValidators } from '~/app/shared/forms/cd-validators';
27 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
28 import { HttpResponse } from '@angular/common/http';
31 selector: 'cd-nvmeof-namespaces-form',
32 templateUrl: './nvmeof-namespaces-form.component.html',
33 styleUrls: ['./nvmeof-namespaces-form.component.scss'],
36 export class NvmeofNamespacesFormComponent implements OnInit {
38 permission: Permission;
39 poolPermission: Permission;
44 description: string = '';
48 subsystems: NvmeofSubsystem[] = [];
49 rbdPools: Array<Pool> = null;
50 rbdImages: any[] = [];
51 initiatorCandidates: { content: string; selected: boolean }[] = [];
56 MAX_NAMESPACE_CREATE: number = 5;
57 MIN_NAMESPACE_CREATE: number = 1;
58 requiredInvalidText: string = $localize`This field is required`;
59 nsCountInvalidText: string = $localize`The namespace count should be between 1 and 5`;
62 public actionLabels: ActionLabelsI18n,
63 private authStorageService: AuthStorageService,
64 private taskWrapperService: TaskWrapperService,
65 private nvmeofService: NvmeofService,
66 private poolService: PoolService,
67 private rbdService: RbdService,
68 private router: Router,
69 private route: ActivatedRoute,
70 public formatterService: FormatterService,
71 public dimlessBinaryPipe: DimlessBinaryPipe
73 this.permission = this.authStorageService.getPermissions().nvmeof;
74 this.poolPermission = this.authStorageService.getPermissions().pool;
75 this.resource = $localize`Namespace`;
76 this.pageURL = 'block/nvmeof/gateways';
81 this.action = this.actionLabels.CREATE;
82 this.title = this.action + ' ' + this.resource;
83 this.description = $localize`Namespaces define the storage volumes that subsystems present to hosts.`;
85 this.route.params.subscribe((params: Params) => {
86 this.subsystemNQN = params['subsystem_nqn'];
87 this.nsid = params['nsid'];
88 if (params['group']) {
89 this.group = params['group'];
91 if (this.subsystemNQN && this.group) {
92 this.pageURL = `block/nvmeof/subsystems/${this.subsystemNQN}/${this.group}`;
93 this.action = this.actionLabels.ADD;
94 this.title = this.action + ' ' + this.resource;
95 this.description = $localize`Create a new namespace associated with this subsystem.`;
98 this.route.queryParams.subscribe((params: Params) => {
99 if (params['group']) {
100 this.group = params['group'];
102 if (params['subsystem_nqn']) {
103 this.subsystemNQN = params['subsystem_nqn'];
105 if (this.subsystemNQN && this.group) {
106 this.pageURL = `block/nvmeof/subsystems/${this.subsystemNQN}/namespaces`;
107 this.action = this.actionLabels.ADD;
108 this.title = this.action + ' ' + this.resource;
109 this.description = $localize`Create a new namespace associated with this subsystem.`;
115 this.poolService.getList().subscribe((resp: Pool[]) => {
116 this.rbdPools = resp.filter(this.rbdService.isRBDPool);
119 this.fetchUsedImages();
120 this.nvmeofService.listSubsystems(this.group).subscribe((subsystems: NvmeofSubsystem[]) => {
121 this.subsystems = subsystems;
122 if (this.subsystemNQN) {
123 const selectedSubsystem = this.subsystems.find((s) => s.nqn === this.subsystemNQN);
124 if (selectedSubsystem) {
125 this.nsForm.get('subsystem').setValue(selectedSubsystem.nqn);
134 this.initForCreate();
135 const subsystemControl = this.nsForm.get('subsystem');
136 if (subsystemControl) {
137 subsystemControl.valueChanges.subscribe((nqn: string) => {
138 this.onSubsystemChange(nqn);
143 // Stores all RBD images fetched for the selected pool
144 private allRbdImages: { name: string; size: number }[] = [];
145 // Maps pool name to a Set of used image names for O(1) lookup
146 private usedRbdImages: Map<string, Set<string>> = new Map();
148 onPoolChange(): void {
149 const pool = this.nsForm.getValue('pool');
153 .list({ pool_name: pool, offset: '0', limit: '-1' })
154 .subscribe((pools: { pool_name: string; value: { name: string; size: number }[] }[]) => {
155 const selectedPool = pools.find((p) => p.pool_name === pool);
156 this.allRbdImages = selectedPool?.value ?? [];
159 const imageControl = this.nsForm.get('rbd_image_name');
160 const currentImage = this.nsForm.getValue('rbd_image_name');
161 if (currentImage && !this.rbdImages.some((img) => img.name === currentImage)) {
162 imageControl.setValue(null);
164 imageControl.markAsUntouched();
165 imageControl.markAsPristine();
169 fetchUsedImages(): void {
170 if (!this.group) return;
172 this.nvmeofService.listNamespaces(this.group).subscribe((response: any) => {
173 const namespaces: NvmeofSubsystemNamespace[] = Array.isArray(response)
175 : response?.namespaces ?? [];
176 this.usedRbdImages = namespaces.reduce((map, ns) => {
177 if (!map.has(ns.rbd_pool_name)) {
178 map.set(ns.rbd_pool_name, new Set<string>());
180 map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name);
182 }, new Map<string, Set<string>>());
187 onSubsystemChange(nqn: string): void {
190 .getInitiators(nqn, this.group)
191 .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
192 const initiators = Array.isArray(response) ? response : response?.hosts || [];
193 this.initiatorCandidates = initiators.map((initiator) => ({
194 content: initiator.nqn,
200 onInitiatorSelection(event: any) {
201 // Carbon ComboBox (selected) emits the full array of selected items
202 const selectedInitiators = Array.isArray(event) ? event.map((e: any) => e.content) : [];
205 .setValue(selectedInitiators.length > 0 ? selectedInitiators : null);
206 this.nsForm.get('initiators').markAsDirty();
207 this.nsForm.get('initiators').markAsTouched();
210 private filterImages(): void {
211 const pool = this.nsForm.getValue('pool');
216 const usedInPool = this.usedRbdImages.get(pool);
217 this.rbdImages = usedInPool
218 ? this.allRbdImages.filter((img) => !usedInPool.has(img.name))
219 : [...this.allRbdImages];
223 this.nsForm = new CdFormGroup({
224 pool: new UntypedFormControl('', {
225 validators: [Validators.required]
227 subsystem: new UntypedFormControl('', {
228 validators: [Validators.required]
230 image_size: new UntypedFormControl(null, {
231 validators: [Validators.required]
233 nsCount: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
235 Validators.max(this.MAX_NAMESPACE_CREATE),
236 Validators.min(this.MIN_NAMESPACE_CREATE)
238 rbd_image_creation: new UntypedFormControl('gateway_provisioned'),
240 rbd_image_name: new UntypedFormControl(null, [
241 CdValidators.custom('rbdImageName', (value: any) => {
242 if (!value) return null;
243 return /^[^@/]+$/.test(value) ? null : { rbdImageName: true };
246 namespace_size: new UntypedFormControl(null, {
247 validators: [CdValidators.blockSizeMultiple()]
248 }), // UI only - not sent to backend
249 host_access: new UntypedFormControl('all'), // UI only - determines visibility
250 initiators: new UntypedFormControl([]) // UI only - selected hosts
253 this.nsForm.get('pool').valueChanges.subscribe(() => {
257 this.nsForm.get('nsCount').valueChanges.subscribe((count: number) => {
259 const creationControl = this.nsForm.get('rbd_image_creation');
260 if (creationControl.value === 'externally_managed') {
261 creationControl.setValue('gateway_provisioned');
266 this.nsForm.get('rbd_image_creation').valueChanges.subscribe((mode: string) => {
267 const nameControl = this.nsForm.get('rbd_image_name');
268 const sizeControl = this.nsForm.get('image_size');
269 const countControl = this.nsForm.get('nsCount');
271 if (mode === 'externally_managed') {
272 countControl.setValue(1);
273 countControl.disable();
275 nameControl.addValidators(Validators.required);
276 sizeControl.removeValidators(Validators.required);
277 sizeControl.disable();
279 sizeControl.enable();
280 countControl.enable();
281 nameControl.removeValidators(Validators.required);
282 sizeControl.addValidators(Validators.required);
284 nameControl.updateValueAndValidity();
285 sizeControl.updateValueAndValidity();
288 this.nsForm.get('host_access').valueChanges.subscribe((mode: string) => {
289 const initiatorsControl = this.nsForm.get('initiators');
290 if (mode === 'specific') {
291 initiatorsControl.addValidators(Validators.required);
293 initiatorsControl.removeValidators(Validators.required);
294 initiatorsControl.setValue([]);
295 this.initiatorCandidates.forEach((i) => (i.selected = false));
297 initiatorsControl.updateValueAndValidity();
302 return Math.random().toString(36).substring(2);
305 private normalizeImageSizeInput(value: string): string {
306 const input = (value || '').trim();
310 // Accept plain numeric values as GiB (e.g. "45" => "45GiB").
311 return /^\d+(\.\d+)?$/.test(input) ? `${input}GiB` : input;
315 rbdImageSize: number,
317 noAutoVisible: boolean
318 ): Observable<HttpResponse<Object>>[] {
319 const pool = this.nsForm.getValue('pool');
320 const requests: Observable<HttpResponse<Object>>[] = [];
321 const creationMode = this.nsForm.getValue('rbd_image_creation');
322 const isGatewayProvisioned = creationMode === 'gateway_provisioned';
324 const loopCount = isGatewayProvisioned ? nsCount : 1;
326 for (let i = 1; i <= loopCount; i++) {
327 const request: NamespaceCreateRequest = {
328 gw_group: this.group,
330 create_image: isGatewayProvisioned,
331 no_auto_visible: noAutoVisible
334 const blockSize = this.nsForm.getValue('namespace_size');
336 request.block_size = blockSize;
339 if (isGatewayProvisioned) {
340 request.rbd_image_name = `nvme_${pool}_${this.group}_${this.randomString()}`;
342 request['rbd_image_size'] = rbdImageSize;
346 const rbdImageName = this.nsForm.getValue('rbd_image_name');
348 request['rbd_image_name'] = rbdImageName;
351 const subsystemNQN = this.nsForm.getValue('subsystem') || this.subsystemNQN;
352 requests.push(this.nvmeofService.createNamespace(subsystemNQN, request));
359 if (this.nsForm.invalid) {
360 this.nsForm.setErrors({ cdSubmitButton: true });
361 this.nsForm.markAllAsTouched();
365 const component = this;
366 const taskUrl: string = `nvmeof/namespace/${URLVerbs.CREATE}`;
367 const image_size = this.nsForm.getValue('image_size');
368 const nsCount = this.nsForm.getValue('nsCount');
369 const hostAccess = this.nsForm.getValue('host_access');
370 const selectedHosts: string[] = this.nsForm.getValue('initiators') || [];
371 const noAutoVisible = hostAccess === 'specific';
372 let action: Observable<any>;
373 let rbdImageSize: number = null;
376 const normalizedSize = this.normalizeImageSizeInput(image_size);
377 rbdImageSize = this.formatterService.toBytes(normalizedSize);
378 if (rbdImageSize === null) {
379 this.nsForm.get('image_size').setErrors({ invalid: true });
380 this.nsForm.setErrors({ cdSubmitButton: true });
385 const subsystemNQN = this.nsForm.getValue('subsystem');
387 // Step 1: Create namespaces
388 // Step 2: If specific hosts selected, chain addNamespaceInitiators calls
389 const createObs = forkJoin(this.buildCreateRequest(rbdImageSize, nsCount, noAutoVisible));
391 const combinedObs = createObs.pipe(
392 switchMap((responses: HttpResponse<Object>[]) => {
393 if (noAutoVisible && selectedHosts.length > 0) {
394 const initiatorObs: Observable<any>[] = [];
396 responses.forEach((res) => {
397 const body: any = res.body;
398 if (body && body.nsid) {
399 selectedHosts.forEach((host: string) => {
400 const req: NamespaceInitiatorRequest = {
401 gw_group: this.group,
402 subsystem_nqn: subsystemNQN || this.subsystemNQN,
405 initiatorObs.push(this.nvmeofService.addNamespaceInitiators(body.nsid, req));
410 if (initiatorObs.length > 0) {
411 return forkJoin(initiatorObs);
414 return of(responses);
418 action = this.taskWrapperService.wrapTaskAroundCall({
419 task: new FinishedTask(taskUrl, {
428 component.nsForm.setErrors({ cdSubmitButton: true });
431 this.router.navigate([this.pageURL], {
432 queryParams: { group: this.group }