NvmeofNamespaceExpandModalComponent,
NvmeSubsystemViewComponent,
NvmeofEditHostKeyModalComponent,
+ NvmeofSubsystemsStepFourComponent,
NvmeofSubsystemOverviewComponent,
- NvmeofSubsystemPerformanceComponent,
- NvmeofSubsystemsStepFourComponent
+ NvmeofSubsystemPerformanceComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
{
path: 'gateways',
component: NvmeofGatewayComponent,
+ data: { breadcrumbs: 'Gateways' },
children: [
{
path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
});
it('should build sidebar items correctly', () => {
- expect(component.sidebarItems.length).toBe(3);
+ expect(component.sidebarItems.length).toBe(5);
- // Verify first item (Initiators)
expect(component.sidebarItems[0].route).toEqual([
'/block/nvmeof/subsystems',
'nqn.test',
- 'hosts'
+ 'overview'
]);
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'
+ 'hosts'
]);
- // Verify third item (Listeners)
expect(component.sidebarItems[2].route).toEqual([
+ '/block/nvmeof/subsystems',
+ 'nqn.test',
+ 'namespaces'
+ ]);
+
+ expect(component.sidebarItems[3].route).toEqual([
'/block/nvmeof/subsystems',
'nqn.test',
'listeners'
]);
+
+ expect(component.sidebarItems[4].route).toEqual([
+ '/block/nvmeof/subsystems',
+ 'nqn.test',
+ 'performance'
+ ]);
});
});
[maxLimit]="25"
identifier="hostname"
forceIdentifier="true"
- [autoReload]="true"
(updateSelection)="updateSelection($event)"
emptyStateTitle="No nodes available"
i18n-emptyStateTitle
<cd-table
[data]="subsystems"
[columns]="columns"
- [autoReload]="true"
columnMode="flex"
selectionType="none"
identifier="nqn"
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ActivatedRoute, Router } from '@angular/router';
+import { ActivatedRoute } from '@angular/router';
import { NvmeofGatewayComponent } from './nvmeof-gateway.component';
let component: NvmeofGatewayComponent;
let fixture: ComponentFixture<NvmeofGatewayComponent>;
let breadcrumbService: BreadcrumbService;
- let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
fixture = TestBed.createComponent(NvmeofGatewayComponent);
component = fixture.componentInstance;
breadcrumbService = TestBed.inject(BreadcrumbService);
- router = TestBed.inject(Router);
fixture.detectChanges();
});
});
it('should update tab crumb on tab switch', () => {
- spyOn(router, 'navigate');
spyOn(breadcrumbService, 'setTabCrumb');
component.onSelected(component.Tabs.subsystem);
- expect(router.navigate).toHaveBeenCalledWith([], {
- relativeTo: TestBed.inject(ActivatedRoute),
- queryParams: { tab: component.Tabs.subsystem },
- queryParamsHandling: 'merge'
- });
expect(breadcrumbService.setTabCrumb).toHaveBeenCalledWith('Subsystem');
});
import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
-import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
-import { Subject } from 'rxjs';
-import { filter, takeUntil } from 'rxjs/operators';
+import { ActivatedRoute, Router } from '@angular/router';
import _ from 'lodash';
export class NvmeofGatewayComponent implements OnInit, OnDestroy {
selectedTab: TABS;
activeTab: TABS = TABS.gateways;
- private readonly destroy$ = new Subject<void>();
@ViewChild('statusTpl', { static: true })
statusTpl: TemplateRef<any>;
) {}
ngOnInit() {
- this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+ this.route.queryParams.subscribe((params) => {
if (params['tab'] && Object.values(TABS).includes(params['tab'])) {
this.activeTab = params['tab'] as TABS;
}
this.breadcrumbService.setTabCrumb(TAB_LABELS[this.activeTab]);
});
-
- this.router.events
- .pipe(
- filter((event) => event instanceof NavigationEnd),
- takeUntil(this.destroy$)
- )
- .subscribe(() => {
- // Run after NavigationEnd handlers so tab crumb is not cleared by global breadcrumb reset.
- setTimeout(() => this.breadcrumbService.setTabCrumb(TAB_LABELS[this.activeTab]));
- });
}
ngOnDestroy() {
- this.destroy$.next();
- this.destroy$.complete();
this.breadcrumbService.clearTabCrumb();
}
onSelected(tab: TABS) {
this.selectedTab = tab;
this.activeTab = tab;
+ this.breadcrumbService.setTabCrumb(TAB_LABELS[tab]);
this.router.navigate([], {
relativeTo: this.route,
queryParams: { tab },
- queryParamsHandling: 'merge'
+ queryParamsHandling: 'merge',
+ replaceUrl: true
});
- this.breadcrumbService.setTabCrumb(TAB_LABELS[tab]);
}
public get Tabs(): typeof TABS {
(fetchData)="listInitiators()"
[columns]="initiatorColumns"
selectionType="multiClick"
- [autoReload]="false"
(updateSelection)="updateSelection($event)">
<div class="table-actions">
<cd-table-actions [permission]="permission"
component.subsystemNQN = 'nqn.2016-06.io.spdk:cnode1';
component.group = 'group1';
component.ngOnInit();
+ fixture.detectChanges();
});
it('should create', () => {
.subscribe({
error: () => {
this.isSubmitLoading = false;
+ this.router.navigate([{ outlets: { modal: null } }], {
+ relativeTo: this.route.parent,
+ queryParamsHandling: 'preserve'
+ });
},
complete: () => {
this.isSubmitLoading = false;
(fetchData)="listListeners()"
[columns]="listenerColumns"
identifier="id"
- [autoReload]="true"
forceIdentifier="true"
selectionType="single"
emptyStateTitle="No listener found."
-import { Component, OnInit } from '@angular/core';
+import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { UntypedFormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Params, Router } from '@angular/router';
import {
private rbdService: RbdService,
private router: Router,
private route: ActivatedRoute,
+ private cdr: ChangeDetectorRef,
public formatterService: FormatterService,
public dimlessBinaryPipe: DimlessBinaryPipe
) {
this.description = $localize`Namespaces define the storage volumes that subsystems present to hosts.`;
this.route.params.subscribe((params: Params) => {
- this.subsystemNQN = params['subsystem_nqn'];
+ if (params['subsystem_nqn']) {
+ this.subsystemNQN = params['subsystem_nqn'];
+ }
this.nsid = params['nsid'];
if (params['group']) {
this.group = params['group'];
});
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('subsystem').setValue(selectedSubsystem.nqn);
+ this.nvmeofService
+ .listSubsystems(this.group)
+ .subscribe((res: NvmeofSubsystem[] | NvmeofSubsystem) => {
+ this.subsystems = Array.isArray(res) ? res : [res];
+ this.cdr.detectChanges();
+ if (this.subsystemNQN) {
+ const selectedSubsystem = this.subsystems.find((s) => s.nqn === this.subsystemNQN);
+ if (selectedSubsystem) {
+ this.nsForm.get('subsystem').setValue(selectedSubsystem.nqn);
+ }
}
- }
- });
+ });
}
}
return Math.random().toString(36).substring(2);
}
- private normalizeImageSizeInput(value: string): string {
- const input = (value || '').trim();
+ private normalizeImageSizeInput(value: string | number): string {
+ const input = String(value ?? '').trim();
if (!input) {
return input;
}
+ if (typeof value === 'number') {
+ return input;
+ }
// Accept plain numeric values as GiB (e.g. "45" => "45GiB").
return /^\d+(\.\d+)?$/.test(input) ? `${input}GiB` : input;
}
}
if (isGatewayProvisioned) {
- request.rbd_image_name = `nvme_${pool}_${this.group}_${this.randomString()}`;
+ const rbdImageName = this.nsForm.getValue('rbd_image_name');
+ if (rbdImageName) {
+ request.rbd_image_name = loopCount > 1 ? `${rbdImageName}-${i}` : rbdImageName;
+ } else {
+ request.rbd_image_name = `nvme_${pool}_${this.group}_${this.randomString()}`;
+ }
if (rbdImageSize) {
request['rbd_image_size'] = rbdImageSize;
}
- }
-
- const rbdImageName = this.nsForm.getValue('rbd_image_name');
- if (rbdImageName) {
- request['rbd_image_name'] = rbdImageName;
+ } else {
+ const rbdImageName = this.nsForm.getValue('rbd_image_name');
+ if (rbdImageName) {
+ request['rbd_image_name'] = rbdImageName;
+ }
}
const subsystemNQN = this.nsForm.getValue('subsystem') || this.subsystemNQN;
},
complete: () => {
this.router.navigate([this.pageURL], {
- queryParams: { group: this.group }
+ queryParams: { group: this.group, tab: 'namespace' }
});
}
});
columnMode="flex"
(fetchData)="fetchData()"
[columns]="namespacesColumns"
+ identifier="unique_id"
+ [forceIdentifier]="true"
selectionType="single"
(updateSelection)="updateSelection($event)"
emptyStateTitle="No namespaces created."
it('should retrieve namespaces', (done) => {
component.group = 'g1';
component.namespaces$.pipe(take(1)).subscribe((namespaces) => {
- expect(namespaces).toEqual(mockNamespaces);
+ expect(namespaces).toEqual(
+ mockNamespaces.map((ns) => ({
+ ...ns,
+ unique_id: `${ns.nsid}_${ns['ns_subsystem_nqn']}`
+ }))
+ );
done();
});
component.listNamespaces();
-import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Component, Input, NgZone, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service';
import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
public actionLabels: ActionLabelsI18n,
private router: Router,
private route: ActivatedRoute,
+ private ngZone: NgZone,
private modalService: ModalCdsService,
private authStorageService: AuthStorageService,
private taskWrapper: TaskWrapperService,
icon: Icons.edit,
click: (row: NvmeofSubsystemNamespace) => {
const namespace = row || this.selection.first();
- this.router.navigate(
- [
- {
- outlets: {
- modal: [URLVerbs.EDIT, namespace.ns_subsystem_nqn, 'namespace', namespace.nsid]
+ this.ngZone.run(() => {
+ this.router.navigate(
+ [
+ {
+ outlets: {
+ modal: [URLVerbs.EDIT, namespace.ns_subsystem_nqn, 'namespace', namespace.nsid]
+ }
}
+ ],
+ {
+ relativeTo: this.route,
+ queryParams: { group: this.group },
+ queryParamsHandling: 'merge'
}
- ],
- {
- relativeTo: this.route,
- queryParams: { group: this.group },
- queryParamsHandling: 'merge'
- }
- );
+ );
+ });
}
},
{
const namespaces = Array.isArray(res) ? res : res.namespaces || [];
// Deduplicate by nsid + subsystem NQN (API with wildcard can return duplicates per gateway)
const seen = new Set<string>();
- return namespaces.filter((ns) => {
- const key = `${ns.nsid}_${ns['ns_subsystem_nqn']}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
+ return namespaces
+ .filter((ns) => {
+ const key = `${ns.nsid}_${ns['ns_subsystem_nqn']}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ })
+ .map((ns) => ({
+ ...ns,
+ unique_id: `${ns.nsid}_${ns['ns_subsystem_nqn']}`
+ }));
}),
catchError(() => of([]))
);
<cd-table [data]="namespaces"
columnMode="flex"
(fetchData)="listNamespaces()"
- [autoReload]="true"
[columns]="namespacesColumns"
selectionType="single"
(updateSelection)="updateSelection($event)"
expect(labelTexts).toContain('Maximum allowed namespaces');
}));
- it('should display subsystem type from subsystem data', fakeAsync(() => {
+ it('should not display MTLS label in overview details', fakeAsync(() => {
component.ngOnInit();
tick();
fixture.detectChanges();
const values = fixture.nativeElement.querySelectorAll('.cds--type-body-compact-01');
const valueTexts = Array.from(values).map((el: HTMLElement) => el.textContent.trim());
- expect(valueTexts).toContain('NVMe');
+ expect(valueTexts).not.toContain('MTLS');
}));
it('should display hosts allowed from subsystem data', fakeAsync(() => {
expect(valueTexts).toContain('Any host');
}));
- it('should display HA status from subsystem data', fakeAsync(() => {
+ it('should not render Edit link for Hosts allowed', fakeAsync(() => {
component.ngOnInit();
tick();
fixture.detectChanges();
- const values = fixture.nativeElement.querySelectorAll('.cds--type-body-compact-01');
- const valueTexts = Array.from(values).map((el: HTMLElement) => el.textContent.trim());
- expect(valueTexts).toContain('Yes');
+ const editLink = fixture.nativeElement.querySelector('a[cdsLink]');
+ expect(editLink).toBeFalsy();
}));
});
[fullWidth]="true">
<div cdsCol
[columnNumbers]="{sm: 4, md: 8, lg: 12}">
- <div cdsRow
- class="form-heading">
- <h3 class="cds--type-heading-03"
- i18n>Review summary</h3>
- </div>
+ <h3 class="cds--type-heading-03 cds-mb-5"
+ i18n>Review summary</h3>
</div>
<!-- Subsystem details -->
import { ActivatedRouteSnapshot, NavigationEnd, NavigationStart, Router } from '@angular/router';
import { concat, from, Observable, of, Subscription } from 'rxjs';
-import { distinct, filter, first, mergeMap, toArray } from 'rxjs/operators';
+import { distinct, filter, first, mergeMap, toArray, take } from 'rxjs/operators';
import { AppConstants } from '~/app/shared/constants/app.constants';
import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs';
private tabCrumbSubscription: Subscription;
private defaultResolver = new BreadcrumbsResolver();
private baseCrumbs: IBreadcrumb[] = [];
- private currentTabCrumb: IBreadcrumb = null;
constructor(
private router: Router,
.pipe(filter((x) => x instanceof NavigationStart))
.subscribe(() => {
this.finished = false;
+ this.breadcrumbService.clearTabCrumb();
});
this.subscription = this.router.events
.pipe(filter((x) => x instanceof NavigationEnd))
.subscribe(() => {
- this.breadcrumbService.clearTabCrumb();
const currentRoot = router.routerState.snapshot.root;
this._resolveCrumbs(currentRoot)
.subscribe((x) => {
this.finished = true;
this.baseCrumbs = x;
- this.crumbs = this.currentTabCrumb ? [...x, this.currentTabCrumb] : [...x];
- const title = this.getTitleFromCrumbs(this.crumbs);
- this.titleService.setTitle(title);
+ this.breadcrumbService.tabCrumb$.pipe(take(1)).subscribe((tabCrumb) => {
+ this.crumbs = tabCrumb && x.length > 0 ? [...x.slice(0, -1), tabCrumb] : [...x];
+ const title = this.getTitleFromCrumbs(this.crumbs);
+ this.titleService.setTitle(title);
+ });
});
});
this.tabCrumbSubscription = this.breadcrumbService.tabCrumb$.subscribe((tabCrumb) => {
- this.currentTabCrumb = tabCrumb;
- if (tabCrumb) {
- this.crumbs = [...this.baseCrumbs, tabCrumb];
+ if (tabCrumb && this.baseCrumbs.length > 0) {
+ this.crumbs = [...this.baseCrumbs.slice(0, -1), tabCrumb];
} else {
this.crumbs = [...this.baseCrumbs];
}
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', () => {