]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/blob
e730bb081e4bd2ef5764a30e8cf4675b72ff5fef
[ceph.git] /
1 import { Component, OnInit } from '@angular/core';
2 import { UntypedFormControl, Validators } from '@angular/forms';
3 import { ActivatedRoute, Params, Router } from '@angular/router';
4 import {
5   NamespaceCreateRequest,
6   NamespaceInitiatorRequest,
7   NvmeofService
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';
12 import {
13   NvmeofSubsystem,
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';
29
30 @Component({
31   selector: 'cd-nvmeof-namespaces-form',
32   templateUrl: './nvmeof-namespaces-form.component.html',
33   styleUrls: ['./nvmeof-namespaces-form.component.scss'],
34   standalone: false
35 })
36 export class NvmeofNamespacesFormComponent implements OnInit {
37   action: string;
38   permission: Permission;
39   poolPermission: Permission;
40   resource: string;
41   pageURL: string;
42
43   title: string = '';
44   description: string = '';
45
46   nsForm: CdFormGroup;
47   subsystemNQN: string;
48   subsystems: NvmeofSubsystem[] = [];
49   rbdPools: Array<Pool> = null;
50   rbdImages: any[] = [];
51   initiatorCandidates: { content: string; selected: boolean }[] = [];
52
53   nsid: string;
54
55   group: string;
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`;
60
61   constructor(
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
72   ) {
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';
77   }
78
79   init() {
80     this.createForm();
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.`;
84
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'];
90       }
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.`;
96       }
97     });
98     this.route.queryParams.subscribe((params: Params) => {
99       if (params['group']) {
100         this.group = params['group'];
101       }
102       if (params['subsystem_nqn']) {
103         this.subsystemNQN = params['subsystem_nqn'];
104       }
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.`;
110       }
111     });
112   }
113
114   initForCreate() {
115     this.poolService.getList().subscribe((resp: Pool[]) => {
116       this.rbdPools = resp.filter(this.rbdService.isRBDPool);
117     });
118     if (this.group) {
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);
126           }
127         }
128       });
129     }
130   }
131
132   ngOnInit() {
133     this.init();
134     this.initForCreate();
135     const subsystemControl = this.nsForm.get('subsystem');
136     if (subsystemControl) {
137       subsystemControl.valueChanges.subscribe((nqn: string) => {
138         this.onSubsystemChange(nqn);
139       });
140     }
141   }
142
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();
147
148   onPoolChange(): void {
149     const pool = this.nsForm.getValue('pool');
150     if (!pool) return;
151
152     this.rbdService
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 ?? [];
157         this.filterImages();
158
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);
163         }
164         imageControl.markAsUntouched();
165         imageControl.markAsPristine();
166       });
167   }
168
169   fetchUsedImages(): void {
170     if (!this.group) return;
171
172     this.nvmeofService.listNamespaces(this.group).subscribe((response: any) => {
173       const namespaces: NvmeofSubsystemNamespace[] = Array.isArray(response)
174         ? 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>());
179         }
180         map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name);
181         return map;
182       }, new Map<string, Set<string>>());
183       this.filterImages();
184     });
185   }
186
187   onSubsystemChange(nqn: string): void {
188     if (!nqn) return;
189     this.nvmeofService
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,
195           selected: false
196         }));
197       });
198   }
199
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) : [];
203     this.nsForm
204       .get('initiators')
205       .setValue(selectedInitiators.length > 0 ? selectedInitiators : null);
206     this.nsForm.get('initiators').markAsDirty();
207     this.nsForm.get('initiators').markAsTouched();
208   }
209
210   private filterImages(): void {
211     const pool = this.nsForm.getValue('pool');
212     if (!pool) {
213       this.rbdImages = [];
214       return;
215     }
216     const usedInPool = this.usedRbdImages.get(pool);
217     this.rbdImages = usedInPool
218       ? this.allRbdImages.filter((img) => !usedInPool.has(img.name))
219       : [...this.allRbdImages];
220   }
221
222   createForm() {
223     this.nsForm = new CdFormGroup({
224       pool: new UntypedFormControl('', {
225         validators: [Validators.required]
226       }),
227       subsystem: new UntypedFormControl('', {
228         validators: [Validators.required]
229       }),
230       image_size: new UntypedFormControl(null, {
231         validators: [Validators.required]
232       }),
233       nsCount: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
234         Validators.required,
235         Validators.max(this.MAX_NAMESPACE_CREATE),
236         Validators.min(this.MIN_NAMESPACE_CREATE)
237       ]),
238       rbd_image_creation: new UntypedFormControl('gateway_provisioned'),
239
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 };
244         })
245       ]),
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
251     });
252
253     this.nsForm.get('pool').valueChanges.subscribe(() => {
254       this.onPoolChange();
255     });
256
257     this.nsForm.get('nsCount').valueChanges.subscribe((count: number) => {
258       if (count > 1) {
259         const creationControl = this.nsForm.get('rbd_image_creation');
260         if (creationControl.value === 'externally_managed') {
261           creationControl.setValue('gateway_provisioned');
262         }
263       }
264     });
265
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');
270
271       if (mode === 'externally_managed') {
272         countControl.setValue(1);
273         countControl.disable();
274         this.onPoolChange();
275         nameControl.addValidators(Validators.required);
276         sizeControl.removeValidators(Validators.required);
277         sizeControl.disable();
278       } else {
279         sizeControl.enable();
280         countControl.enable();
281         nameControl.removeValidators(Validators.required);
282         sizeControl.addValidators(Validators.required);
283       }
284       nameControl.updateValueAndValidity();
285       sizeControl.updateValueAndValidity();
286     });
287
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);
292       } else {
293         initiatorsControl.removeValidators(Validators.required);
294         initiatorsControl.setValue([]);
295         this.initiatorCandidates.forEach((i) => (i.selected = false));
296       }
297       initiatorsControl.updateValueAndValidity();
298     });
299   }
300
301   randomString() {
302     return Math.random().toString(36).substring(2);
303   }
304
305   private normalizeImageSizeInput(value: string): string {
306     const input = (value || '').trim();
307     if (!input) {
308       return input;
309     }
310     // Accept plain numeric values as GiB (e.g. "45" => "45GiB").
311     return /^\d+(\.\d+)?$/.test(input) ? `${input}GiB` : input;
312   }
313
314   buildCreateRequest(
315     rbdImageSize: number,
316     nsCount: 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';
323
324     const loopCount = isGatewayProvisioned ? nsCount : 1;
325
326     for (let i = 1; i <= loopCount; i++) {
327       const request: NamespaceCreateRequest = {
328         gw_group: this.group,
329         rbd_pool: pool,
330         create_image: isGatewayProvisioned,
331         no_auto_visible: noAutoVisible
332       };
333
334       const blockSize = this.nsForm.getValue('namespace_size');
335       if (blockSize) {
336         request.block_size = blockSize;
337       }
338
339       if (isGatewayProvisioned) {
340         request.rbd_image_name = `nvme_${pool}_${this.group}_${this.randomString()}`;
341         if (rbdImageSize) {
342           request['rbd_image_size'] = rbdImageSize;
343         }
344       }
345
346       const rbdImageName = this.nsForm.getValue('rbd_image_name');
347       if (rbdImageName) {
348         request['rbd_image_name'] = rbdImageName;
349       }
350
351       const subsystemNQN = this.nsForm.getValue('subsystem') || this.subsystemNQN;
352       requests.push(this.nvmeofService.createNamespace(subsystemNQN, request));
353     }
354
355     return requests;
356   }
357
358   onSubmit() {
359     if (this.nsForm.invalid) {
360       this.nsForm.setErrors({ cdSubmitButton: true });
361       this.nsForm.markAllAsTouched();
362       return;
363     }
364
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;
374
375     if (image_size) {
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 });
381         return;
382       }
383     }
384
385     const subsystemNQN = this.nsForm.getValue('subsystem');
386
387     // Step 1: Create namespaces
388     // Step 2: If specific hosts selected, chain addNamespaceInitiators calls
389     const createObs = forkJoin(this.buildCreateRequest(rbdImageSize, nsCount, noAutoVisible));
390
391     const combinedObs = createObs.pipe(
392       switchMap((responses: HttpResponse<Object>[]) => {
393         if (noAutoVisible && selectedHosts.length > 0) {
394           const initiatorObs: Observable<any>[] = [];
395
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,
403                   host_nqn: host
404                 };
405                 initiatorObs.push(this.nvmeofService.addNamespaceInitiators(body.nsid, req));
406               });
407             }
408           });
409
410           if (initiatorObs.length > 0) {
411             return forkJoin(initiatorObs);
412           }
413         }
414         return of(responses);
415       })
416     );
417
418     action = this.taskWrapperService.wrapTaskAroundCall({
419       task: new FinishedTask(taskUrl, {
420         nqn: subsystemNQN,
421         nsCount
422       }),
423       call: combinedObs
424     });
425
426     action.subscribe({
427       error: () => {
428         component.nsForm.setErrors({ cdSubmitButton: true });
429       },
430       complete: () => {
431         this.router.navigate([this.pageURL], {
432           queryParams: { group: this.group }
433         });
434       }
435     });
436   }
437 }