]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: NVMe Onboarding Setup Cards 69241/head
authorpujashahu <pujashahu@li-3d22414c-327d-11b2-a85c-a49077722390.ibm.com>
Tue, 2 Jun 2026 18:35:45 +0000 (00:05 +0530)
committerpujashahu <pshahu@redhat.com>
Tue, 30 Jun 2026 12:02:06 +0000 (17:32 +0530)
Fixes: https://tracker.ceph.com/issues/77067
Signed-off-by: pujaoshahu <pshahu@redhat.com>
32 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html

index 3c5c8d9e7ccccb4b6d9d3664bca5b630fdde43b7..135ec9134b4a5feb816512f8ce362f5d0e06d942 100644 (file)
@@ -106,6 +106,8 @@ import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem
 import { NvmeofSubsystemPerformanceComponent } from './nvmeof-subsystem-performance/nvmeof-subsystem-performance.component';
 import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component';
 import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component';
+import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards/nvmeof-setup-cards.component';
+import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
 
 @NgModule({
   imports: [
@@ -142,7 +144,9 @@ import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-gr
     ContainedListModule,
     SideNavModule,
     LayoutModule,
-    ThemeModule
+    ThemeModule,
+    NvmeofSetupCardsComponent,
+    NvmeofGatewayGroupFilterComponent
   ],
   declarations: [
     RbdListComponent,
@@ -337,6 +341,7 @@ const routes: Routes = [
   {
     path: 'nvmeof',
     canActivate: [ModuleStatusGuardService],
+    component: NvmeofTabsComponent,
     data: {
       breadcrumbs: true,
       text: 'NVMe/TCP',
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html
new file mode 100644 (file)
index 0000000..d3b43ab
--- /dev/null
@@ -0,0 +1,22 @@
+<div cdsGrid
+     [useCssGrid]="true"
+     [narrow]="true"
+     [fullWidth]="true">
+<div cdsCol
+     [columnNumbers]="{sm: 4, md: 8}">
+  <div class="form-item"
+       cdsRow>
+    <cds-combo-box
+        type="single"
+        label="Selected Gateway Group"
+        i18n-label
+        [placeholder]="placeholder"
+        [items]="items"
+        (selected)="onSelected($event)"
+        (clear)="onClear()"
+        [disabled]="disabled">
+      <cds-dropdown-list></cds-dropdown-list>
+    </cds-combo-box>
+  </div>
+</div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts
new file mode 100644 (file)
index 0000000..aef3218
--- /dev/null
@@ -0,0 +1,111 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router } from '@angular/router';
+import { of, throwError } from 'rxjs';
+
+import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter.component';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+const MOCK_GROUPS_RESPONSE = [
+  [
+    { spec: { group: 'grp1' }, service_name: 'nvmeof.grp1' },
+    { spec: { group: 'grp2' }, service_name: 'nvmeof.grp2' }
+  ]
+];
+
+describe('NvmeofGatewayGroupFilterComponent', () => {
+  let fixture: ComponentFixture<NvmeofGatewayGroupFilterComponent>;
+  let component: NvmeofGatewayGroupFilterComponent;
+  let nvmeofService: Partial<NvmeofService>;
+  let routerSpy: Partial<Router>;
+  let activatedRoute: Partial<ActivatedRoute>;
+
+  beforeEach(async () => {
+    nvmeofService = {
+      listGatewayGroups: jest.fn().mockReturnValue(of(MOCK_GROUPS_RESPONSE)),
+      formatGwGroupsList: jest.fn().mockReturnValue([{ content: 'grp1' }, { content: 'grp2' }])
+    };
+
+    routerSpy = {
+      navigate: jest.fn().mockResolvedValue(true)
+    };
+
+    activatedRoute = {
+      snapshot: {
+        queryParams: {}
+      } as any
+    };
+
+    await TestBed.configureTestingModule({
+      imports: [NvmeofGatewayGroupFilterComponent],
+      providers: [
+        { provide: NvmeofService, useValue: nvmeofService },
+        { provide: Router, useValue: routerSpy },
+        { provide: ActivatedRoute, useValue: activatedRoute }
+      ]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofGatewayGroupFilterComponent);
+    component = fixture.componentInstance;
+  });
+
+  it('should create', () => {
+    fixture.detectChanges();
+    expect(component).toBeTruthy();
+  });
+
+  it('should load gateway groups on init', () => {
+    fixture.detectChanges();
+    expect(nvmeofService.listGatewayGroups).toHaveBeenCalled();
+    expect(component.items.length).toBe(2);
+  });
+
+  it('should default to the first group when no group is in the URL', () => {
+    fixture.detectChanges();
+    expect(routerSpy.navigate).toHaveBeenCalledWith(
+      [],
+      expect.objectContaining({ queryParams: { group: 'grp1' } })
+    );
+  });
+
+  it('should mark the URL group as selected when a group is already in the URL', () => {
+    (activatedRoute as any).snapshot.queryParams = { group: 'grp2' };
+    fixture.detectChanges();
+    const selected = component.items.find((i) => i.selected);
+    expect(selected?.content).toBe('grp2');
+  });
+
+  it('should emit groupChange when a group is selected', () => {
+    fixture.detectChanges();
+    const emitSpy = jest.spyOn(component.groupChange, 'emit');
+    component.onSelected({ content: 'grp2' });
+    expect(emitSpy).toHaveBeenCalledWith('grp2');
+  });
+
+  it('should emit null groupChange when cleared', () => {
+    fixture.detectChanges();
+    const emitSpy = jest.spyOn(component.groupChange, 'emit');
+    component.onClear();
+    expect(emitSpy).toHaveBeenCalledWith(null);
+  });
+
+  it('should disable and set placeholder when no groups are available', () => {
+    (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(of([[]]));
+    fixture.detectChanges();
+    expect(component.disabled).toBe(true);
+    expect(component.placeholder).toBe('No groups available');
+  });
+
+  it('should disable and set error placeholder on API error', () => {
+    const err = new Error('network error');
+    (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(throwError(() => err));
+    fixture.detectChanges();
+    expect(component.disabled).toBe(true);
+    expect(component.placeholder).toBe('Unable to fetch Gateway groups');
+  });
+
+  it('should render a cds-combo-box', () => {
+    fixture.detectChanges();
+    const combo = fixture.nativeElement.querySelector('cds-combo-box');
+    expect(combo).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts
new file mode 100644 (file)
index 0000000..39dd35f
--- /dev/null
@@ -0,0 +1,115 @@
+import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
+import { ActivatedRoute, Router, RouterModule } from '@angular/router';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { ComboBoxModule, GridModule, LayoutModule } from 'carbon-components-angular';
+import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
+
+const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
+
+@Component({
+  selector: 'cd-nvmeof-gateway-group-filter',
+  templateUrl: './nvmeof-gateway-group-filter.component.html',
+  styleUrls: ['./nvmeof-gateway-group-filter.component.scss'],
+  standalone: true,
+  imports: [ComboBoxModule, GridModule, LayoutModule, RouterModule]
+})
+export class NvmeofGatewayGroupFilterComponent implements OnInit, OnDestroy {
+  @Output() groupChange = new EventEmitter<string | null>();
+
+  items: GroupsComboboxItem[] = [];
+  disabled = false;
+  placeholder = DEFAULT_PLACEHOLDER;
+
+  private destroy$ = new Subject<void>();
+
+  constructor(
+    private nvmeofService: NvmeofService,
+    private route: ActivatedRoute,
+    private router: Router
+  ) {}
+
+  ngOnInit(): void {
+    this.loadGatewayGroups();
+  }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
+  onSelected(item: GroupsComboboxItem): void {
+    this.syncQueryParam(item.content);
+  }
+
+  onClear(): void {
+    this.syncQueryParam(null);
+  }
+
+  private loadGatewayGroups(): void {
+    this.nvmeofService
+      .listGatewayGroups()
+      .pipe(takeUntil(this.destroy$))
+      .subscribe({
+        next: (response: CephServiceSpec[][]) => this.onGroupsLoaded(response),
+        error: (error: any) => this.onGroupsError(error)
+      });
+  }
+
+  private onGroupsLoaded(response: CephServiceSpec[][]): void {
+    if (response?.[0]?.length) {
+      this.items = this.nvmeofService.formatGwGroupsList(response);
+    } else {
+      this.items = [];
+    }
+    this.syncSelectionState();
+  }
+
+  private onGroupsError(error: any): void {
+    this.items = [];
+    this.disabled = true;
+    this.placeholder = $localize`Unable to fetch Gateway groups`;
+    if (error?.preventDefault) {
+      error.preventDefault();
+    }
+    this.groupChange.emit(null);
+  }
+
+  private syncSelectionState(): void {
+    if (this.items.length) {
+      this.disabled = false;
+      this.placeholder = DEFAULT_PLACEHOLDER;
+      const urlGroup = this.route.snapshot.queryParams['group']?.trim() || null;
+      if (!urlGroup) {
+        this.syncQueryParam(this.items[0].content);
+      } else {
+        this.items = this.items.map((g) => ({ ...g, selected: g.content === urlGroup }));
+        this.groupChange.emit(urlGroup);
+      }
+    } else {
+      this.disabled = true;
+      this.placeholder = $localize`No groups available`;
+      this.groupChange.emit(null);
+    }
+  }
+
+  private syncQueryParam(group: string | null): void {
+    const currentGroup = this.route.snapshot.queryParams['group']?.trim() || null;
+    if (currentGroup === group) {
+      // URL already correct â€” still emit so the parent gets the current value on init
+      this.items = this.items.map((g) => ({ ...g, selected: g.content === group }));
+      this.groupChange.emit(group);
+      return;
+    }
+
+    this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: { group: group || null },
+      queryParamsHandling: 'merge',
+      replaceUrl: true
+    });
+    this.items = this.items.map((g) => ({ ...g, selected: g.content === group }));
+    this.groupChange.emit(group);
+  }
+}
index 69fad9acbfd5b385680343c32cf766476a371f79..fe2100fc90ba7bfd1930591b49fe741dbaa42106 100644 (file)
@@ -3,13 +3,15 @@ import { NO_ERRORS_SCHEMA } from '@angular/core';
 import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group.component';
 import { GridModule, TabsModule, ModalModule } from 'carbon-components-angular';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { of } from 'rxjs';
+import { Observable, of, Subject } from 'rxjs';
 import { HttpClientModule } from '@angular/common/http';
 import { SharedModule } from '~/app/shared/shared.module';
 import { Router } from '@angular/router';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
 import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { NvmeofStateService } from '../nvmeof-state.service';
 
 describe('NvmeofGatewayGroupComponent', () => {
   let component: NvmeofGatewayGroupComponent;
@@ -22,6 +24,11 @@ describe('NvmeofGatewayGroupComponent', () => {
       listSubsystems: jest.fn().mockReturnValue(of([]))
     };
 
+    const nvmeofStateServiceMock = {
+      refresh$: new Subject<void>(),
+      requestRefresh: jest.fn()
+    };
+
     await TestBed.configureTestingModule({
       imports: [HttpClientModule, SharedModule, TabsModule, GridModule, ModalModule],
       declarations: [NvmeofGatewayGroupComponent],
@@ -34,7 +41,8 @@ describe('NvmeofGatewayGroupComponent', () => {
         {
           provide: ModalCdsService,
           useValue: { show: jest.fn() }
-        }
+        },
+        { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
       ],
       schemas: [NO_ERRORS_SCHEMA]
     }).compileComponents();
@@ -298,4 +306,39 @@ describe('NvmeofGatewayGroupComponent', () => {
       );
     });
   });
+
+  it('should refresh table and setup state after gateway group delete completes', () => {
+    const modalService = TestBed.inject(ModalCdsService);
+    const taskWrapperService = TestBed.inject(TaskWrapperService);
+    const nvmeofStateService = TestBed.inject(NvmeofStateService);
+
+    jest.spyOn(modalService, 'show').mockImplementation(() => undefined);
+    jest.spyOn(taskWrapperService, 'wrapTaskAroundCall').mockReturnValue(
+      new Observable((observer) => {
+        observer.complete();
+      })
+    );
+
+    const refreshBtnSpy = jest.fn();
+    component.table = { refreshBtn: refreshBtnSpy } as any;
+    const requestRefreshSpy = jest.spyOn(nvmeofStateService, 'requestRefresh');
+
+    component.selection = {
+      first: () => ({
+        service_name: 'nvmeof.rbd.default',
+        spec: { group: 'default' },
+        subSystemCount: 0
+      }),
+      hasSelection: true
+    } as any;
+
+    component.deleteGatewayGroupModal();
+
+    const submitActionObservable = (modalService.show as jest.Mock).mock.calls[0][1]
+      .submitActionObservable;
+    submitActionObservable().subscribe();
+
+    expect(refreshBtnSpy).toHaveBeenCalled();
+    expect(requestRefreshSpy).toHaveBeenCalled();
+  });
 });
index 933a085a1ffe21aa520e579a08b7d02c2dbcc360..5d622a0bdef561f86e63a6bf156b84eca329f5ad 100644 (file)
@@ -1,7 +1,14 @@
-import { Component, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core';
+import {
+  Component,
+  OnDestroy,
+  OnInit,
+  TemplateRef,
+  ViewChild,
+  ViewEncapsulation
+} from '@angular/core';
 import { Router } from '@angular/router';
-import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs';
-import { catchError, map, switchMap } from 'rxjs/operators';
+import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs';
+import { catchError, finalize, map, shareReplay, switchMap, takeUntil, tap } from 'rxjs/operators';
 import { GatewayGroup, NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { HostService } from '~/app/shared/api/host.service';
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
@@ -25,6 +32,7 @@ import { NotificationService } from '~/app/shared/services/notification.service'
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { URLBuilderService } from '~/app/shared/services/url-builder.service';
 import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component';
+import { NvmeofStateService } from '../nvmeof-state.service';
 
 const BASE_URL = 'block/nvmeof/gateways';
 
@@ -36,7 +44,9 @@ const BASE_URL = 'block/nvmeof/gateways';
   encapsulation: ViewEncapsulation.None,
   providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
 })
-export class NvmeofGatewayGroupComponent implements OnInit {
+export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
+  private destroy$ = new Subject<void>();
+
   @ViewChild(TableComponent, { static: true })
   table: TableComponent;
 
@@ -63,6 +73,7 @@ export class NvmeofGatewayGroupComponent implements OnInit {
   gatewayGroupName: string;
   subsystemCount: number;
   gatewayCount: number;
+  private lastGroupCount = 0;
 
   viewUrl = `/${BASE_URL}/view`;
   icons = Icons;
@@ -79,7 +90,8 @@ export class NvmeofGatewayGroupComponent implements OnInit {
     public taskWrapper: TaskWrapperService,
     private notificationService: NotificationService,
     private urlBuilder: URLBuilderService,
-    private router: Router
+    private router: Router,
+    private nvmeofStateService: NvmeofStateService
   ) {}
 
   ngOnInit(): void {
@@ -172,9 +184,20 @@ export class NvmeofGatewayGroupComponent implements OnInit {
             return of([]);
           })
         )
-      )
+      ),
+      shareReplay({ bufferSize: 1, refCount: true }),
+      tap((groups) => {
+        const wasNonEmpty = this.lastGroupCount > 0;
+        this.lastGroupCount = groups.length;
+        if (wasNonEmpty && groups.length === 0) {
+          this.nvmeofStateService.requestRefresh();
+        }
+      })
     );
     this.checkNodesAvailability();
+    this.nvmeofStateService.refresh$
+      .pipe(takeUntil(this.destroy$))
+      .subscribe(() => this.fetchData());
   }
   fetchData(): void {
     this.subject.next([]);
@@ -240,17 +263,19 @@ export class NvmeofGatewayGroupComponent implements OnInit {
             call: this.cephServiceService.delete(serviceName)
           })
           .pipe(
-            map(() => {
-              this.table.refreshBtn();
+            tap({
+              complete: () => {
+                this.nvmeofStateService.requestRefresh();
+              }
             }),
             catchError((error) => {
-              this.table.refreshBtn();
               this.notificationService.show(
                 NotificationType.error,
                 $localize`${`Failed to delete gateway group ${selectedGroup.spec.group}: ${error.message}`}`
               );
               return of(null);
-            })
+            }),
+            finalize(() => this.table?.refreshBtn())
           );
       }
     });
@@ -299,4 +324,9 @@ export class NvmeofGatewayGroupComponent implements OnInit {
     }
     this.router.navigate([this.viewUrl, groupName]);
   }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
 }
index 88d2d4f5fd59b0152ab897841f58fc1e35a16f45..3223e7a1c416f1a3d680e4dbbe21f18bca79316f 100644 (file)
@@ -44,8 +44,6 @@ export class NvmeofGatewayComponent implements OnInit, OnDestroy {
     this.route.queryParams.subscribe((params) => {
       if (params['tab'] && Object.values(TABS).includes(params['tab'])) {
         this.activeTab = params['tab'] as TABS;
-      } else {
-        this.activeTab = TABS.gateways;
       }
       this.breadcrumbService.setTabCrumb(TAB_LABELS[this.activeTab]);
     });
index 79c82c3c4a2ae8176af5bff97ea4db5910c4e849..38c5c2fc78c3f73421cdd48c70835102adaf0639 100644 (file)
@@ -1,27 +1,6 @@
-<cd-nvmeof-tabs></cd-nvmeof-tabs>
-
-<div cdsGrid
-     [useCssGrid]="true"
-     [narrow]="true"
-     [fullWidth]="true">
-<div cdsCol
-     [columnNumbers]="{sm: 4, md: 8}">
-  <div class="cds-mt-3 form-item"
-       cdsRow>
-    <cds-combo-box
-        type="single"
-        label="Selected Gateway Group"
-        i18n-label
-        [placeholder]="gwGroupPlaceholder"
-        [items]="gwGroups"
-        (selected)="onGroupSelection($event)"
-        (clear)="onGroupClear()"
-        [disabled]="gwGroupsEmpty">
-      <cds-dropdown-list></cds-dropdown-list>
-    </cds-combo-box>
-  </div>
-</div>
-</div>
+<cd-nvmeof-gateway-group-filter
+  (groupChange)="onGroupChange($event)">
+</cd-nvmeof-gateway-group-filter>
 
 <ng-container *ngIf="namespaces$ | async as namespaces">
   <cd-table [data]="namespaces"
index 6ac05c63b699f3d1459a18950096987b08733375..f64b74416b977b4c04d2d6a656c8a44182c12e40 100644 (file)
@@ -1,12 +1,13 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { HttpClientModule } from '@angular/common/http';
-import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
-import { of } from 'rxjs';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { of, Subject } from 'rxjs';
 import { take } from 'rxjs/operators';
 import { RouterTestingModule } from '@angular/router/testing';
 import { SharedModule } from '~/app/shared/shared.module';
 
 import { NvmeofService } from '../../../shared/api/nvmeof.service';
+import { NvmeofStateService } from '../nvmeof-state.service';
 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';
@@ -63,6 +64,11 @@ describe('NvmeofNamespacesListComponent', () => {
   let modalService: MockModalCdsService;
 
   beforeEach(async () => {
+    const nvmeofStateServiceMock = {
+      refresh$: new Subject<void>(),
+      requestRefresh: jest.fn()
+    };
+
     await TestBed.configureTestingModule({
       declarations: [NvmeofNamespacesListComponent, NvmeofSubsystemsDetailsComponent],
       imports: [HttpClientModule, RouterTestingModule, SharedModule],
@@ -70,9 +76,10 @@ describe('NvmeofNamespacesListComponent', () => {
         { provide: NvmeofService, useClass: MockNvmeOfService },
         { provide: AuthStorageService, useClass: MockAuthStorageService },
         { provide: ModalCdsService, useClass: MockModalCdsService },
-        { provide: TaskWrapperService, useClass: MockTaskWrapperService }
+        { provide: TaskWrapperService, useClass: MockTaskWrapperService },
+        { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
       ],
-      schemas: [CUSTOM_ELEMENTS_SCHEMA]
+      schemas: [NO_ERRORS_SCHEMA]
     }).compileComponents();
 
     fixture = TestBed.createComponent(NvmeofNamespacesListComponent);
@@ -98,7 +105,7 @@ describe('NvmeofNamespacesListComponent', () => {
       );
       done();
     });
-    component.listNamespaces();
+    component.fetchData();
   });
 
   it('should open delete modal with correct data', () => {
index 37b1aa4d3a6803d55802df433697d6d7918c6df5..3217bc23cd8d92e962ea51faefb83a785103bd03 100644 (file)
@@ -1,6 +1,6 @@
 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 { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
@@ -11,16 +11,14 @@ import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
 import { Permission } from '~/app/shared/models/permissions';
-import { CephServiceSpec } from '~/app/shared/models/service.interface';
 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
 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';
+import { NvmeofStateService } from '../nvmeof-state.service';
 import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
-import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
-
-const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
+import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
 
 @Component({
   selector: 'cd-nvmeof-namespaces-list',
@@ -42,11 +40,6 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
   namespaces$: Observable<NvmeofSubsystemNamespace[]>;
   private namespaceSubject = new BehaviorSubject<void>(undefined);
 
-  // Gateway group dropdown properties
-  gwGroups: GroupsComboboxItem[] = [];
-  gwGroupsEmpty: boolean = false;
-  gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER;
-
   private destroy$ = new Subject<void>();
 
   constructor(
@@ -58,16 +51,13 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
     private authStorageService: AuthStorageService,
     private taskWrapper: TaskWrapperService,
     private nvmeofService: NvmeofService,
-    private dimlessBinaryPipe: DimlessBinaryPipe
+    private dimlessBinaryPipe: DimlessBinaryPipe,
+    private nvmeofStateService: NvmeofStateService
   ) {
     this.permission = this.authStorageService.getPermissions().nvmeof;
   }
 
   ngOnInit() {
-    this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
-      if (params?.['group']) this.onGroupSelection({ content: params?.['group'] });
-    });
-    this.setGatewayGroups();
     this.namespacesColumns = [
       {
         name: $localize`Namespace ID`,
@@ -166,78 +156,24 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
       }),
       takeUntil(this.destroy$)
     );
+    this.nvmeofStateService.refresh$
+      .pipe(takeUntil(this.destroy$))
+      .subscribe(() => this.fetchData());
   }
 
-  updateSelection(selection: CdTableSelection) {
-    this.selection = selection;
+  onGroupChange(group: string | null): void {
+    this.group = group;
+    this.namespaceSubject.next();
   }
 
-  listNamespaces() {
-    this.namespaceSubject.next();
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
   }
 
   fetchData() {
     this.namespaceSubject.next();
   }
 
-  // Gateway groups methods
-  onGroupSelection(selected: GroupsComboboxItem) {
-    selected.selected = true;
-    this.group = selected.content;
-    this.listNamespaces();
-  }
-
-  onGroupClear() {
-    this.group = null;
-    this.listNamespaces();
-  }
-
-  setGatewayGroups() {
-    this.nvmeofService
-      .listGatewayGroups()
-      .pipe(takeUntil(this.destroy$))
-      .subscribe({
-        next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response),
-        error: (error) => this.handleGatewayGroupsError(error)
-      });
-  }
-
-  handleGatewayGroupsSuccess(response: CephServiceSpec[][]) {
-    if (response?.[0]?.length) {
-      this.gwGroups = this.nvmeofService.formatGwGroupsList(response);
-    } else {
-      this.gwGroups = [];
-    }
-    this.updateGroupSelectionState();
-  }
-
-  updateGroupSelectionState() {
-    if (this.gwGroups.length) {
-      if (!this.group) {
-        this.onGroupSelection(this.gwGroups[0]);
-      } else {
-        this.gwGroups = this.gwGroups.map((g) => ({
-          ...g,
-          selected: g.content === this.group
-        }));
-      }
-      this.gwGroupsEmpty = false;
-      this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER;
-    } else {
-      this.gwGroupsEmpty = true;
-      this.gwGroupPlaceholder = $localize`No groups available`;
-    }
-  }
-
-  handleGatewayGroupsError(error: any) {
-    this.gwGroups = [];
-    this.gwGroupsEmpty = true;
-    this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`;
-    if (error?.preventDefault) {
-      error?.preventDefault?.();
-    }
-  }
-
   deleteNamespaceModal() {
     const namespace = this.selection.first();
     const subsystemNqn = namespace.ns_subsystem_nqn;
@@ -251,13 +187,15 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
         deletionMessage: $localize`Deleting the namespace <strong>${namespace.nsid}</strong> will permanently remove all resources, services, and configurations within it. This action cannot be undone.`
       },
       submitActionObservable: () =>
-        this.taskWrapper.wrapTaskAroundCall({
-          task: new FinishedTask('nvmeof/namespace/delete', {
-            nqn: subsystemNqn,
-            nsid: namespace.nsid
-          }),
-          call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.group)
-        })
+        this.taskWrapper
+          .wrapTaskAroundCall({
+            task: new FinishedTask('nvmeof/namespace/delete', {
+              nqn: subsystemNqn,
+              nsid: namespace.nsid
+            }),
+            call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.group)
+          })
+          .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() }))
     });
   }
 
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html
new file mode 100644 (file)
index 0000000..d4fb4f1
--- /dev/null
@@ -0,0 +1,57 @@
+<cd-productive-card>
+  <ng-template #header>
+    <div cdsStack="vertical"
+         [gap]="3">
+      <h2 class="cds--type-heading-03"
+          i18n>Recommended first-time setup</h2>
+      <p class="cds--type-body-01"
+         i18n>
+        Start your NVMe over Fabrics configuration by creating the essential resources in sequence.
+      </p>
+    </div>
+  </ng-template>
+
+  <div cdsStack="horizontal" [gap]="4">
+    <div>
+      <cd-setup-step-card
+        [stepNumber]="1"
+        [title]="cards.gateway.title"
+        [description]="cards.gateway.description"
+        [isConfigured]="hasGatewayGroups"
+        [successMessage]="cards.gateway.successMessage"
+        [infoMessage]="cards.gateway.infoMessage">
+      </cd-setup-step-card>
+    </div>
+
+    <div >
+      <cd-setup-step-card
+        [stepNumber]="2"
+        [title]="cards.subsystem.title"
+        [description]="cards.subsystem.description"
+        [isConfigured]="hasSubsystems"
+        [successMessage]="cards.subsystem.successMessage"
+        [infoMessage]="hasGatewayGroups ? cards.subsystem.infoMessage : gatewayPendingMessage">
+      </cd-setup-step-card>
+    </div>
+
+    <div >
+      <cd-setup-step-card
+        [stepNumber]="3"
+        [title]="cards.namespace.title"
+        [description]="cards.namespace.description"
+        [isConfigured]="hasNamespaces"
+        [successMessage]="cards.namespace.successMessage"
+        [infoMessage]="hasGatewayGroups ? cards.namespace.infoMessage : gatewayPendingMessage">
+      </cd-setup-step-card>
+    </div>
+  </div>
+
+  @if (isAllConfigured) {
+  <div class="nvmeof-setup-cards__completion">
+    <a cdsLink
+       class="cds--link--disabled"
+       aria-disabled="true"
+       i18n>Configuration complete. View status â†’</a>
+  </div>
+  }
+</cd-productive-card>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts
new file mode 100644 (file)
index 0000000..a41e9e2
--- /dev/null
@@ -0,0 +1,160 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterModule } from '@angular/router';
+
+import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards.component';
+
+describe('NvmeofSetupCardsComponent', () => {
+  let component: NvmeofSetupCardsComponent;
+  let fixture: ComponentFixture<NvmeofSetupCardsComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [NvmeofSetupCardsComponent, RouterModule.forRoot([])]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofSetupCardsComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should render 3 setup step cards', () => {
+    const cards = fixture.nativeElement.querySelectorAll('cd-setup-step-card');
+    expect(cards.length).toBe(3);
+  });
+
+  it('should not show completion link when isAllConfigured is false', () => {
+    component.isAllConfigured = false;
+    fixture.detectChanges();
+    const link = fixture.nativeElement.querySelector('.nvmeof-setup-cards__completion');
+    expect(link).toBeNull();
+  });
+
+  it('should show completion link when isAllConfigured is true', () => {
+    component.isAllConfigured = true;
+    fixture.detectChanges();
+    const link = fixture.nativeElement.querySelector('.nvmeof-setup-cards__completion');
+    expect(link).toBeTruthy();
+  });
+
+  describe('setup state', () => {
+    const getStepCards = () =>
+      fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card');
+
+    it('should show gateway-only setup state when gateway exists but subsystems and namespaces do not', () => {
+      component.hasGatewayGroups = true;
+      component.hasSubsystems = false;
+      component.hasNamespaces = false;
+      fixture.detectChanges();
+
+      const cards = getStepCards();
+      expect(cards[0].componentInstance.statusMessage).toBe(
+        'Gateway group configured successfully.'
+      );
+      expect(cards[1].componentInstance.statusMessage).toBe(
+        'No subsystem configured for this cluster yet.'
+      );
+      expect(cards[2].componentInstance.statusMessage).toBe(
+        'No namespace allocated or mapped yet.'
+      );
+    });
+
+    it('should show subsystem-complete setup state when gateway and subsystem exist', () => {
+      component.hasGatewayGroups = true;
+      component.hasSubsystems = true;
+      component.hasNamespaces = false;
+      fixture.detectChanges();
+
+      const cards = getStepCards();
+      expect(cards[0].componentInstance.statusMessage).toBe(
+        'Gateway group configured successfully.'
+      );
+      expect(cards[1].componentInstance.statusMessage).toBe('Subsystem configured successfully.');
+      expect(cards[2].componentInstance.statusMessage).toBe(
+        'No namespace allocated or mapped yet.'
+      );
+    });
+  });
+
+  it('should display "No gateway configured yet." on step 2 and 3 when hasGatewayGroups is false', () => {
+    component.hasGatewayGroups = false;
+    component.hasSubsystems = false;
+    component.hasNamespaces = false;
+    fixture.detectChanges();
+
+    const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card');
+    const subsystemCard = cardElements[1].componentInstance;
+    const namespaceCard = cardElements[2].componentInstance;
+
+    expect(subsystemCard.statusMessage).toBe('No gateway configured yet.');
+    expect(namespaceCard.statusMessage).toBe('No gateway configured yet.');
+  });
+
+  it('should display original info messages when hasGatewayGroups is true', () => {
+    component.hasGatewayGroups = true;
+    component.hasSubsystems = false;
+    component.hasNamespaces = false;
+    fixture.detectChanges();
+
+    const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card');
+    const subsystemCard = cardElements[1].componentInstance;
+    const namespaceCard = cardElements[2].componentInstance;
+
+    expect(subsystemCard.statusMessage).toBe('No subsystem configured for this cluster yet.');
+    expect(namespaceCard.statusMessage).toBe('No namespace allocated or mapped yet.');
+  });
+
+  it('should display gateway info message when hasGatewayGroups is false', () => {
+    component.hasGatewayGroups = false;
+    fixture.detectChanges();
+
+    const gatewayCard = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card')[0]
+      .componentInstance;
+
+    expect(gatewayCard.statusMessage).toBe('No gateway groups configured for this cluster yet.');
+  });
+
+  it('should display success messages when all setup steps are configured', () => {
+    component.hasGatewayGroups = true;
+    component.hasSubsystems = true;
+    component.hasNamespaces = true;
+    fixture.detectChanges();
+
+    const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card');
+
+    expect(cardElements[0].componentInstance.statusMessage).toBe(
+      'Gateway group configured successfully.'
+    );
+    expect(cardElements[1].componentInstance.statusMessage).toBe(
+      'Subsystem configured successfully.'
+    );
+    expect(cardElements[2].componentInstance.statusMessage).toBe('Namespaces mapped successfully.');
+  });
+
+  it('should update all step messages when gateway groups are removed after full configuration', () => {
+    component.hasGatewayGroups = true;
+    component.hasSubsystems = true;
+    component.hasNamespaces = true;
+    fixture.detectChanges();
+
+    const getCards = () => fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card');
+
+    expect(getCards()[0].componentInstance.statusMessage).toBe(
+      'Gateway group configured successfully.'
+    );
+
+    component.hasGatewayGroups = false;
+    component.hasSubsystems = false;
+    component.hasNamespaces = false;
+    fixture.detectChanges();
+
+    expect(getCards()[0].componentInstance.statusMessage).toBe(
+      'No gateway groups configured for this cluster yet.'
+    );
+    expect(getCards()[1].componentInstance.statusMessage).toBe('No gateway configured yet.');
+    expect(getCards()[2].componentInstance.statusMessage).toBe('No gateway configured yet.');
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts
new file mode 100644 (file)
index 0000000..9dc9100
--- /dev/null
@@ -0,0 +1,53 @@
+import { CommonModule } from '@angular/common';
+import { Component, Input, ViewEncapsulation } from '@angular/core';
+import { RouterModule } from '@angular/router';
+import { LayoutModule, LayerModule, LinkModule, TilesModule } from 'carbon-components-angular';
+import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
+import { SetupStepCardComponent } from '~/app/shared/components/setup-step-card/setup-step-card.component';
+
+@Component({
+  selector: 'cd-nvmeof-setup-cards',
+  templateUrl: './nvmeof-setup-cards.component.html',
+  styleUrls: ['./nvmeof-setup-cards.component.scss'],
+  standalone: true,
+  encapsulation: ViewEncapsulation.None,
+  imports: [
+    CommonModule,
+    RouterModule,
+    LayoutModule,
+    LayerModule,
+    TilesModule,
+    LinkModule,
+    ProductiveCardComponent,
+    SetupStepCardComponent
+  ]
+})
+export class NvmeofSetupCardsComponent {
+  @Input() hasGatewayGroups = false;
+  @Input() hasSubsystems = false;
+  @Input() hasNamespaces = false;
+  @Input() isAllConfigured = false;
+
+  readonly gatewayPendingMessage = $localize`No gateway configured yet.`;
+
+  readonly cards = {
+    gateway: {
+      title: $localize`Create Gateway groups`,
+      description: $localize`Group NVMe gateway nodes to enable high availability and load balancing for storage targets.`,
+      successMessage: $localize`Gateway group configured successfully.`,
+      infoMessage: $localize`No gateway groups configured for this cluster yet.`
+    },
+    subsystem: {
+      title: $localize`Create Subsystems`,
+      description: $localize`Define storage targets by creating NVMe subsystems and configuring security, listeners, and host access.`,
+      successMessage: $localize`Subsystem configured successfully.`,
+      infoMessage: $localize`No subsystem configured for this cluster yet.`
+    },
+    namespace: {
+      title: $localize`Create Namespaces`,
+      description: $localize`Create storage namespaces backed by Ceph block images. This completes your NVMe over Fabrics setup.`,
+      successMessage: $localize`Namespaces mapped successfully.`,
+      infoMessage: $localize`No namespace allocated or mapped yet.`
+    }
+  };
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts
new file mode 100644 (file)
index 0000000..ccdd04d
--- /dev/null
@@ -0,0 +1,187 @@
+import { TestBed } from '@angular/core/testing';
+
+import { Subject } from 'rxjs';
+
+import { NvmeofStateService } from './nvmeof-state.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+
+describe('NvmeofStateService', () => {
+  let service: NvmeofStateService;
+  let summaryData$: Subject<any>;
+  let summaryServiceSpy: any;
+
+  const emitSummary = (tasks: any[]) => summaryData$.next({ finished_tasks: tasks });
+
+  beforeEach(() => {
+    summaryData$ = new Subject<any>();
+    summaryServiceSpy = {
+      subscribe: jest.fn().mockImplementation((callback: (s: any) => void) => {
+        return summaryData$.subscribe(callback);
+      })
+    };
+
+    TestBed.configureTestingModule({
+      providers: [NvmeofStateService, { provide: SummaryService, useValue: summaryServiceSpy }]
+    });
+
+    service = TestBed.inject(NvmeofStateService);
+  });
+
+  it('should create', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should emit refresh$ when requestRefresh is called', (done) => {
+    service.refresh$.subscribe(() => done());
+    service.requestRefresh();
+  });
+
+  it('should not emit refresh$ on the first summary update (initialization baseline)', () => {
+    const refreshSpy = jest.fn();
+    service.refresh$.subscribe(refreshSpy);
+
+    emitSummary([
+      {
+        name: 'service/delete',
+        begin_time: '2026-01-01T00:00:00Z',
+        metadata: { service_name: 'nvmeof.rbd.default' }
+      }
+    ]);
+
+    expect(refreshSpy).not.toHaveBeenCalled();
+  });
+
+  it('should emit refresh$ when a new NVMe service/delete task appears', () => {
+    const refreshSpy = jest.fn();
+    service.refresh$.subscribe(refreshSpy);
+
+    emitSummary([]); // init
+    emitSummary([
+      {
+        name: 'service/delete',
+        begin_time: '2026-01-01T00:00:00Z',
+        metadata: { service_name: 'nvmeof.rbd.default' }
+      }
+    ]);
+
+    expect(refreshSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('should emit refresh$ when a new NVMe service/create task appears', () => {
+    const refreshSpy = jest.fn();
+    service.refresh$.subscribe(refreshSpy);
+
+    emitSummary([]); // init
+    emitSummary([
+      {
+        name: 'service/create',
+        begin_time: '2026-01-01T00:00:00Z',
+        metadata: { service_name: 'nvmeof.rbd.default' }
+      }
+    ]);
+
+    expect(refreshSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('should emit refresh$ when a nvmeof/subsystem/delete task appears', () => {
+    const refreshSpy = jest.fn();
+    service.refresh$.subscribe(refreshSpy);
+
+    emitSummary([]); // init
+    emitSummary([
+      {
+        name: 'nvmeof/subsystem/delete',
+        begin_time: '2026-01-01T00:00:00Z',
+        metadata: { nqn: 'sub1' }
+      }
+    ]);
+
+    expect(refreshSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('should emit refresh$ when a nvmeof/namespace/create task appears', () => {
+    const refreshSpy = jest.fn();
+    service.refresh$.subscribe(refreshSpy);
+
+    emitSummary([]); // init
+    emitSummary([
+      {
+        name: 'nvmeof/namespace/create',
+        begin_time: '2026-01-01T00:00:00Z',
+        metadata: { nqn: 'sub1' }
+      }
+    ]);
+
+    expect(refreshSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('should emit refresh$ when a nvmeof/namespace/delete task appears', () => {
+    const refreshSpy = jest.fn();
+    service.refresh$.subscribe(refreshSpy);
+
+    emitSummary([]); // init
+    emitSummary([
+      {
+        name: 'nvmeof/namespace/delete',
+        begin_time: '2026-01-01T00:00:00Z',
+        metadata: { nsid: 1, nqn: 'sub1' }
+      }
+    ]);
+
+    expect(refreshSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('should not emit refresh$ for non-NVMe tasks', () => {
+    const refreshSpy = jest.fn();
+    service.refresh$.subscribe(refreshSpy);
+
+    emitSummary([]); // init
+    emitSummary([{ name: 'rbd/create', begin_time: '2026-01-01T00:00:00Z', metadata: {} }]);
+
+    expect(refreshSpy).not.toHaveBeenCalled();
+  });
+
+  it('should not emit refresh$ for service/delete of a non-NVMe service', () => {
+    const refreshSpy = jest.fn();
+    service.refresh$.subscribe(refreshSpy);
+
+    emitSummary([]); // init
+    emitSummary([
+      {
+        name: 'service/delete',
+        begin_time: '2026-01-01T00:00:00Z',
+        metadata: { service_name: 'rbd.default' }
+      }
+    ]);
+
+    expect(refreshSpy).not.toHaveBeenCalled();
+  });
+
+  it('should not emit refresh$ for the same task appearing again', () => {
+    const refreshSpy = jest.fn();
+    service.refresh$.subscribe(refreshSpy);
+
+    const task = {
+      name: 'service/delete',
+      begin_time: '2026-01-01T00:00:00Z',
+      metadata: { service_name: 'nvmeof.rbd.default' }
+    };
+
+    emitSummary([]); // init
+    emitSummary([task]); // new task â€” emits once
+    emitSummary([task]); // same task â€” no second emit
+
+    expect(refreshSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('should unsubscribe from SummaryService on destroy', () => {
+    const unsubscribeSpy = jest.fn();
+    const mockSubscription = { unsubscribe: unsubscribeSpy };
+    summaryServiceSpy.subscribe.mockReturnValueOnce(mockSubscription);
+
+    const freshService = new NvmeofStateService(summaryServiceSpy as any);
+    freshService.ngOnDestroy();
+
+    expect(unsubscribeSpy).toHaveBeenCalled();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts
new file mode 100644 (file)
index 0000000..c8343a2
--- /dev/null
@@ -0,0 +1,95 @@
+import { Injectable, OnDestroy } from '@angular/core';
+
+import { merge, Subject, Subscription } from 'rxjs';
+
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Summary } from '~/app/shared/models/summary.model';
+import { SummaryService } from '~/app/shared/services/summary.service';
+
+/**
+ * Provides a unified refresh$ stream for NVMe-oF UI components to react to
+ * task completions and explicit refresh requests.
+ *
+ * A dedicated service is used instead of reusing TaskListService because
+ * TaskListService is tightly coupled to the task-list UI component and does
+ * not expose a clean observable suitable for driving side-effects such as
+ * setup-card state reloads.
+ *
+ * This service is provided at the NvmeofTabsComponent level to ensure
+ * subscriptions are properly destroyed when navigating away from nvmeof pages.
+ */
+@Injectable()
+export class NvmeofStateService implements OnDestroy {
+  private readonly explicitRefreshSource = new Subject<void>();
+  private readonly taskRefreshSource = new Subject<void>();
+  private summarySubscription: Subscription;
+  private seenTaskSignatures = new Set<string>();
+  private summaryInitialized = false;
+
+  readonly refresh$ = merge(
+    this.explicitRefreshSource.asObservable(),
+    this.taskRefreshSource.asObservable()
+  );
+
+  constructor(private summaryService: SummaryService) {
+    this.summarySubscription = this.summaryService.subscribe((summary: Summary) => {
+      const nvmeTasks = (summary?.finished_tasks ?? []).filter((task: FinishedTask) =>
+        this.isNvmeTask(task)
+      );
+      const currentSignatures = new Set(
+        nvmeTasks.map((task: FinishedTask) => this.getTaskSignature(task))
+      );
+
+      if (!this.summaryInitialized) {
+        this.summaryInitialized = true;
+        this.seenTaskSignatures = currentSignatures;
+        return;
+      }
+
+      const hasNewTask = [...currentSignatures].some(
+        (taskSignature) => !this.seenTaskSignatures.has(taskSignature)
+      );
+      this.seenTaskSignatures = currentSignatures;
+
+      if (hasNewTask) {
+        this.taskRefreshSource.next();
+      }
+    });
+  }
+
+  ngOnDestroy(): void {
+    this.summarySubscription?.unsubscribe();
+  }
+
+  requestRefresh(): void {
+    this.explicitRefreshSource.next();
+  }
+
+  private isNvmeTask(task: FinishedTask): boolean {
+    if (!task?.name) {
+      return false;
+    }
+
+    if (task.name === 'service/create' || task.name === 'service/delete') {
+      const serviceName = task.metadata?.['service_name'];
+      return typeof serviceName === 'string' && serviceName.startsWith('nvmeof.');
+    }
+
+    return [
+      'nvmeof/gateway/create',
+      'nvmeof/gateway/delete',
+      'nvmeof/subsystem/create',
+      'nvmeof/subsystem/delete',
+      'nvmeof/namespace/create',
+      'nvmeof/namespace/delete'
+    ].includes(task.name);
+  }
+
+  private getTaskSignature(task: FinishedTask): string {
+    return JSON.stringify({
+      name: task.name,
+      begin_time: task.begin_time,
+      metadata: task.metadata
+    });
+  }
+}
index 68927708547a05ec08b690664e4dabf9e5eb0d2f..57fa67a8b896b226615a1ab6ff46ad61bdc910df 100644 (file)
@@ -2,10 +2,11 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testin
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { RouterTestingModule } from '@angular/router/testing';
 import { ActivatedRoute } from '@angular/router';
-import { of } from 'rxjs';
+import { of, Subject } from 'rxjs';
 
 import { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list.component';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NvmeofStateService } from '../nvmeof-state.service';
 import { SharedModule } from '~/app/shared/shared.module';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 
@@ -42,6 +43,11 @@ describe('NvmeofSubsystemNamespacesListComponent', () => {
   }
 
   beforeEach(async () => {
+    const nvmeofStateServiceMock = {
+      refresh$: new Subject<void>(),
+      requestRefresh: jest.fn()
+    };
+
     await TestBed.configureTestingModule({
       declarations: [NvmeofSubsystemNamespacesListComponent],
       imports: [HttpClientTestingModule, RouterTestingModule, SharedModule],
@@ -61,7 +67,8 @@ describe('NvmeofSubsystemNamespacesListComponent', () => {
             listNamespaces: jest.fn().mockReturnValue(of(mockNamespaces))
           }
         },
-        { provide: AuthStorageService, useClass: MockAuthStorageService }
+        { provide: AuthStorageService, useClass: MockAuthStorageService },
+        { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
       ]
     }).compileComponents();
   });
index 852f0a006d860c97541262a59551b163ae192a47..fe8e6591d662951a1ff7ce626d73b6aa2e5343bd 100644 (file)
@@ -14,8 +14,9 @@ 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';
 import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { NvmeofStateService } from '../nvmeof-state.service';
 import { combineLatest, Subject } from 'rxjs';
-import { takeUntil } from 'rxjs/operators';
+import { catchError, takeUntil, tap } from 'rxjs/operators';
 
 @Component({
   selector: 'cd-nvmeof-subsystem-namespaces-list',
@@ -35,7 +36,6 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy
   private destroy$ = new Subject<void>();
 
   constructor(
-    // ... constructor stays mostly same
     public actionLabels: ActionLabelsI18n,
     private router: Router,
     private modalService: ModalCdsService,
@@ -44,7 +44,8 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy
     private nvmeofService: NvmeofService,
     private dimlessBinaryPipe: DimlessBinaryPipe,
     private iopsPipe: IopsPipe,
-    private route: ActivatedRoute
+    private route: ActivatedRoute,
+    private nvmeofStateService: NvmeofStateService
   ) {
     this.permission = this.authStorageService.getPermissions().nvmeof;
   }
@@ -155,7 +156,13 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy
     if (this.group) {
       this.nvmeofService
         .listNamespaces(this.group, this.subsystemNQN)
-        .pipe(takeUntil(this.destroy$))
+        .pipe(
+          catchError(() => {
+            this.namespaces = [];
+            return [];
+          }),
+          takeUntil(this.destroy$)
+        )
         .subscribe((res: NvmeofSubsystemNamespace[]) => {
           this.namespaces = res || [];
         });
@@ -171,13 +178,15 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy
       itemNames: [namespace.nsid],
       actionDescription: 'delete',
       submitActionObservable: () =>
-        this.taskWrapper.wrapTaskAroundCall({
-          task: new FinishedTask('nvmeof/namespace/delete', {
-            nqn: this.subsystemNQN,
-            nsid: namespace.nsid
-          }),
-          call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group)
-        })
+        this.taskWrapper
+          .wrapTaskAroundCall({
+            task: new FinishedTask('nvmeof/namespace/delete', {
+              nqn: this.subsystemNQN,
+              nsid: namespace.nsid
+            }),
+            call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group)
+          })
+          .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() }))
     });
   }
 
index 3111ae2e8d4dbed1bcc786ba62b609a9037eb9a6..aef098c0d865378a9597686299b50bf217a969b2 100644 (file)
@@ -1,27 +1,7 @@
-<cd-nvmeof-tabs></cd-nvmeof-tabs>
+<cd-nvmeof-gateway-group-filter
+  (groupChange)="onGroupChange($event)">
+</cd-nvmeof-gateway-group-filter>
 
-<div cdsGrid
-     [useCssGrid]="true"
-     [narrow]="true"
-     [fullWidth]="true">
-<div cdsCol
-     [columnNumbers]="{sm: 4, md: 8}">
-  <div class="cds-mt-3 form-item"
-       cdsRow>
-    <cds-combo-box
-        type="single"
-        label="Selected Gateway Group"
-        i18n-label
-        [placeholder]="gwGroupPlaceholder"
-        [items]="gwGroups"
-        (selected)="onGroupSelection($event)"
-        (clear)="onGroupClear()"
-        [disabled]="gwGroupsEmpty">
-      <cds-dropdown-list></cds-dropdown-list>
-    </cds-combo-box>
-  </div>
-</div>
-</div>
 <ng-container *ngIf="subsystems$ | async as subsystems">
   <cd-table #table
             [data]="subsystems"
index a3532ec1e12e420d08409a8bbbf9a9246d7833e4..f848d67c66ea32fa5b2083f150542a4dbe21ca52 100644 (file)
@@ -1,6 +1,8 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { HttpClientModule } from '@angular/common/http';
-import { of } from 'rxjs';
+import { ActivatedRoute, Router } from '@angular/router';
+import { BehaviorSubject, of, Subject } from 'rxjs';
+import { skip, take } from 'rxjs/operators';
 import { RouterTestingModule } from '@angular/router/testing';
 import { SharedModule } from '~/app/shared/shared.module';
 
@@ -10,8 +12,9 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { NvmeofSubsystemsComponent } from './nvmeof-subsystems.component';
 import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
+import { NvmeofGatewayGroupFilterComponent } from '../nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
 import { ComboBoxModule, GridModule } from 'carbon-components-angular';
-import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { NvmeofStateService } from '../nvmeof-state.service';
 
 const mockSubsystems = [
   {
@@ -27,38 +30,27 @@ const mockSubsystems = [
   }
 ];
 
-const mockGroups = [
-  [
-    {
-      service_name: 'nvmeof.rbd.default',
-      service_type: 'nvmeof',
-      unmanaged: false,
-      spec: {
-        group: 'default'
-      }
-    },
-    {
-      service_name: 'nvmeof.rbd.foo',
-      service_type: 'nvmeof',
-      unmanaged: false,
-      spec: {
-        group: 'foo'
-      }
-    }
-  ],
-  2
-];
+class MockNvmeOfService {
+  listGatewayGroups() {
+    return of([
+      [
+        {
+          service_name: 'nvmeof.default',
+          service_type: 'nvmeof',
+          service_id: 'default',
+          spec: { group: 'default' }
+        }
+      ]
+    ]);
+  }
 
-const mockformattedGwGroups = [
-  {
-    content: 'default'
-  },
-  {
-    content: 'foo'
+  formatGwGroupsList(response: any) {
+    return (response?.[0] || []).map((g: any) => ({
+      content: g.spec.group,
+      selected: false
+    }));
   }
-];
 
-class MockNvmeOfService {
   listSubsystems() {
     return of(mockSubsystems);
   }
@@ -66,14 +58,6 @@ class MockNvmeOfService {
   getInitiators() {
     return of([]);
   }
-
-  formatGwGroupsList(_data: CephServiceSpec[][]) {
-    return mockformattedGwGroups;
-  }
-
-  listGatewayGroups() {
-    return of(mockGroups);
-  }
 }
 
 class MockAuthStorageService {
@@ -89,21 +73,55 @@ class MockTaskWrapperService {}
 describe('NvmeofSubsystemsComponent', () => {
   let component: NvmeofSubsystemsComponent;
   let fixture: ComponentFixture<NvmeofSubsystemsComponent>;
+  let nvmeofService: MockNvmeOfService;
+  let queryParams$: BehaviorSubject<Record<string, string>>;
+  const activatedRouteMock = {
+    queryParams: null as any,
+    snapshot: { queryParams: {} as Record<string, string> }
+  };
 
   beforeEach(async () => {
+    queryParams$ = new BehaviorSubject<Record<string, string>>({});
+    activatedRouteMock.queryParams = queryParams$.asObservable();
+    activatedRouteMock.snapshot.queryParams = queryParams$.value;
+
+    const nvmeofStateServiceMock = {
+      refresh$: new Subject<void>(),
+      requestRefresh: jest.fn()
+    };
+
     await TestBed.configureTestingModule({
       declarations: [NvmeofSubsystemsComponent, NvmeofSubsystemsDetailsComponent],
-      imports: [HttpClientModule, RouterTestingModule, SharedModule, ComboBoxModule, GridModule],
+      imports: [
+        HttpClientModule,
+        RouterTestingModule,
+        SharedModule,
+        ComboBoxModule,
+        GridModule,
+        NvmeofGatewayGroupFilterComponent
+      ],
       providers: [
         { provide: NvmeofService, useClass: MockNvmeOfService },
         { provide: AuthStorageService, useClass: MockAuthStorageService },
         { provide: ModalCdsService, useClass: MockModalService },
-        { provide: TaskWrapperService, useClass: MockTaskWrapperService }
+        { provide: TaskWrapperService, useClass: MockTaskWrapperService },
+        { provide: ActivatedRoute, useValue: activatedRouteMock },
+        { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
       ]
     }).compileComponents();
 
+    const router = TestBed.inject(Router);
+    jest.spyOn(router, 'navigate').mockImplementation((_commands, extras?) => {
+      const group = extras?.queryParams?.['group'];
+      const params = group ? { group: String(group) } : {};
+      activatedRouteMock.snapshot.queryParams = params;
+      queryParams$.next(params);
+      return Promise.resolve(true);
+    });
+
     fixture = TestBed.createComponent(NvmeofSubsystemsComponent);
     component = fixture.componentInstance;
+    nvmeofService = TestBed.inject(NvmeofService) as any;
     component.ngOnInit();
     fixture.detectChanges();
   });
@@ -115,22 +133,31 @@ describe('NvmeofSubsystemsComponent', () => {
   it('should retrieve subsystems', (done) => {
     const expected = mockSubsystems.map((s) => ({
       ...s,
-      gw_group: component.group,
+      gw_group: 'default',
       auth: 'No authentication',
       initiator_count: 0
     }));
-    component.subsystems$.subscribe((subsystems) => {
+    component.onGroupChange('default');
+    component.subsystems$.pipe(skip(1), take(1)).subscribe((subsystems) => {
       expect(subsystems).toEqual(expected);
       done();
     });
-    component.getSubsystems();
+    component.fetchData();
   });
 
-  it('should load gateway groups correctly', () => {
-    expect(component.gwGroups.length).toBe(2);
+  it('should not fetch subsystems when group is not selected', (done) => {
+    const listSubsystemsSpy = jest.spyOn(nvmeofService, 'listSubsystems');
+    component.group = null;
+    component.fetchData();
+
+    component.subsystems$.pipe(take(1)).subscribe((subsystems) => {
+      expect(subsystems).toEqual([]);
+      expect(listSubsystemsSpy).not.toHaveBeenCalled();
+      done();
+    });
   });
 
   it('should set first group as default initially', () => {
-    expect(component.group).toBe(mockGroups[0][0].spec.group);
+    expect(component.group).toBe('default');
   });
 });
index 85590b314b5f583d17a4fe18894c3b1f0981d38c..cc2467f06526241273e931b50ddd47ba4e136a7a 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
+import { Router } from '@angular/router';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import {
@@ -18,15 +18,14 @@ import { Icons } from '~/app/shared/enum/icons.enum';
 import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
-import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { NvmeofStateService } from '../nvmeof-state.service';
 import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs';
 import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
 import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
 
 const BASE_URL = 'block/nvmeof/subsystems';
-const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
 
 @Component({
   selector: 'cd-nvmeof-subsystems',
@@ -55,10 +54,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
   tableActions: CdTableAction[];
   subsystemDetails: any[];
   context: CdTableFetchDataContext;
-  gwGroups: GroupsComboboxItem[] = [];
   group: string = null;
-  gwGroupsEmpty: boolean = false;
-  gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER;
   authType = NvmeofSubsystemAuthType;
   subsystems$: Observable<(NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[]>;
   private subsystemSubject = new BehaviorSubject<void>(undefined);
@@ -70,19 +66,15 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
     private authStorageService: AuthStorageService,
     public actionLabels: ActionLabelsI18n,
     private router: Router,
-    private route: ActivatedRoute,
     private modalService: ModalCdsService,
-    private taskWrapper: TaskWrapperService
+    private taskWrapper: TaskWrapperService,
+    private nvmeofStateService: NvmeofStateService
   ) {
     super();
     this.permissions = this.authStorageService.getPermissions();
   }
 
   ngOnInit() {
-    this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
-      if (params?.['group']) this.onGroupSelection({ content: params?.['group'] });
-    });
-    this.setGatewayGroups();
     this.subsystemsColumns = [
       {
         name: $localize`Subsystem NQN`,
@@ -152,14 +144,18 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
       }),
       takeUntil(this.destroy$)
     );
+    this.nvmeofStateService.refresh$
+      .pipe(takeUntil(this.destroy$))
+      .subscribe(() => this.fetchData());
   }
 
-  updateSelection(selection: CdTableSelection) {
-    this.selection = selection;
+  onGroupChange(group: string | null): void {
+    this.group = group;
+    this.subsystemSubject.next();
   }
 
-  getSubsystems() {
-    this.subsystemSubject.next();
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
   }
 
   fetchData() {
@@ -179,68 +175,15 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
         forceDeleteAcknowledgementMessage: $localize`I understand this may remove resources still attached to this subsystem.`
       },
       submitActionObservable: () =>
-        this.taskWrapper.wrapTaskAroundCall({
-          task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }),
-          call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.group)
-        })
+        this.taskWrapper
+          .wrapTaskAroundCall({
+            task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }),
+            call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.group)
+          })
+          .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() }))
     });
   }
 
-  onGroupSelection(selected: GroupsComboboxItem) {
-    selected.selected = true;
-    this.group = selected.content;
-    this.getSubsystems();
-  }
-
-  onGroupClear() {
-    this.group = null;
-    this.getSubsystems();
-  }
-
-  setGatewayGroups() {
-    this.nvmeofService
-      .listGatewayGroups()
-      .pipe(takeUntil(this.destroy$))
-      .subscribe({
-        next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response),
-        error: (error) => this.handleGatewayGroupsError(error)
-      });
-  }
-
-  handleGatewayGroupsSuccess(response: CephServiceSpec[][]) {
-    if (response?.[0]?.length) {
-      this.gwGroups = this.nvmeofService.formatGwGroupsList(response);
-    } else {
-      this.gwGroups = [];
-    }
-    this.updateGroupSelectionState();
-  }
-
-  updateGroupSelectionState() {
-    if (this.gwGroups.length) {
-      this.gwGroupsEmpty = false;
-      this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER;
-      if (!this.group) {
-        this.onGroupSelection(this.gwGroups[0]);
-      } else {
-        this.gwGroups = this.gwGroups.map((g) => ({
-          ...g,
-          selected: g.content === this.group
-        }));
-      }
-    } else {
-      this.gwGroupsEmpty = true;
-      this.gwGroupPlaceholder = $localize`No groups available`;
-    }
-  }
-
-  handleGatewayGroupsError(error: any) {
-    this.gwGroups = [];
-    this.gwGroupsEmpty = true;
-    this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`;
-    this.handleError(error);
-  }
-
   private handleError(error: any): void {
     if (error?.preventDefault) {
       error?.preventDefault?.();
index 9b3388838d5d0ead225b80ac655816949fb863d0..a4f9f1e52410fc6e115a0246e69d3118db7af3c1 100644 (file)
@@ -1,31 +1,45 @@
-<fieldset>
-  <legend>
-    <h1 class="cds--type-heading-03">NVMe over Fabrics (TCP)</h1>
-  <cd-help-text>Monitor and manage NVMe-over-TCP resources for high-performance block storage.</cd-help-text>
-  </legend>
-</fieldset>
-<section>
-  <cds-tabs type="contained"
-            followFocus="true"
-            isNavigation="true"
-            [cacheActive]="false">
-  <cds-tab
-    heading="Gateways"
-    i18n-heading
-    [active]="activeTab === Tabs.gateways"
-    (selected)="onSelected(Tabs.gateways)">
-  </cds-tab>
-  <cds-tab
-    heading="Subsystems"
-    i18n-heading
-    [active]="activeTab === Tabs.subsystems"
-    (selected)="onSelected(Tabs.subsystems)">
-  </cds-tab>
-  <cds-tab
-    heading="Namespaces"
-    i18n-heading
-    [active]="activeTab === Tabs.namespaces"
-    (selected)="onSelected(Tabs.namespaces)">
-  </cds-tab>
-</cds-tabs>
-</section>
+@if (showTabsShell) {
+  <fieldset>
+    <legend class="cds-mb-5">
+      <h1 class="cds--type-heading-04">NVMe over Fabrics (TCP)</h1>
+      <p class="cds--type-body-01" i18n>Monitor and manage NVMe-over-TCP resources for high-performance block storage.</p>
+    </legend>
+  </fieldset>
+
+  @if (showSetupCards) {
+  <cd-nvmeof-setup-cards
+                         [hasGatewayGroups]="hasGatewayGroups"
+                         [hasSubsystems]="hasSubsystems"
+                         [hasNamespaces]="hasNamespaces"
+                         [isAllConfigured]="isAllConfigured">
+  </cd-nvmeof-setup-cards>
+  }
+
+  <section class="cds-mt-5">
+    <cds-tabs type="contained"
+              followFocus="true"
+              isNavigation="true"
+              [cacheActive]="false">
+    <cds-tab
+      heading="Gateways"
+      i18n-heading
+      [active]="activeTab === Tabs.gateways"
+      (selected)="onSelected(Tabs.gateways)">
+    </cds-tab>
+    <cds-tab
+      heading="Subsystems"
+      i18n-heading
+      [active]="activeTab === Tabs.subsystems"
+      (selected)="onSelected(Tabs.subsystems)">
+    </cds-tab>
+    <cds-tab
+      heading="Namespaces"
+      i18n-heading
+      [active]="activeTab === Tabs.namespaces"
+      (selected)="onSelected(Tabs.namespaces)">
+    </cds-tab>
+  </cds-tabs>
+  </section>
+}
+
+<router-outlet></router-outlet>
index cdd1ea2e9cfda690be80d2b5bef933e0055105e8..b97da033c23435372c6f24f65a7c70c61a5f247f 100644 (file)
@@ -1,26 +1,79 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { Router } from '@angular/router';
+import { ActivatedRoute, Event as RouterEvent, NavigationEnd, Router } from '@angular/router';
 import { RouterTestingModule } from '@angular/router/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { BehaviorSubject, Subject, of } from 'rxjs';
 
 import { TabsModule } from 'carbon-components-angular';
 
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NvmeofStateService } from '../nvmeof-state.service';
 import { NvmeofTabsComponent } from './nvmeof-tabs.component';
 import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofSetupCardsComponent } from '../nvmeof-setup-cards/nvmeof-setup-cards.component';
+
+type SetupState = {
+  hasGatewayGroups: boolean;
+  hasSubsystems: boolean;
+  hasNamespaces: boolean;
+};
 
 describe('NvmeofTabsComponent', () => {
   let component: NvmeofTabsComponent;
   let fixture: ComponentFixture<NvmeofTabsComponent>;
   let router: Router;
+  let nvmeofServiceSpy: any;
+  let queryParams$: BehaviorSubject<any>;
+  let refresh$: Subject<void>;
+  let routerEvents$: Subject<RouterEvent>;
+  let currentSetupState: SetupState;
+
+  const setQueryParams = (params: any) => queryParams$.next(params);
+  const emitRefresh = () => refresh$.next();
+  const setSetupState = (state: SetupState) => {
+    currentSetupState = state;
+    nvmeofServiceSpy.fetchSetupState.mockReturnValue(of(currentSetupState));
+  };
 
   beforeEach(async () => {
-    await TestBed.configureTestingModule({
+    queryParams$ = new BehaviorSubject<any>({ group: 'grp1' });
+    refresh$ = new Subject<void>();
+    const nvmeofStateServiceMock = {
+      refresh$: refresh$.asObservable()
+    };
+    currentSetupState = { hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true };
+    nvmeofServiceSpy = {
+      fetchSetupState: jest.fn().mockImplementation(() => of(currentSetupState))
+    };
+
+    TestBed.configureTestingModule({
       declarations: [NvmeofTabsComponent],
-      imports: [RouterTestingModule, SharedModule, TabsModule]
-    }).compileComponents();
+      imports: [
+        RouterTestingModule,
+        HttpClientTestingModule,
+        SharedModule,
+        TabsModule,
+        NvmeofSetupCardsComponent
+      ],
+      providers: [
+        { provide: NvmeofService, useValue: nvmeofServiceSpy },
+        { provide: ActivatedRoute, useValue: { queryParams: queryParams$.asObservable() } }
+      ]
+    });
+    TestBed.overrideComponent(NvmeofTabsComponent, {
+      set: { providers: [{ provide: NvmeofStateService, useValue: nvmeofStateServiceMock }] }
+    });
+    await TestBed.compileComponents();
 
     fixture = TestBed.createComponent(NvmeofTabsComponent);
     component = fixture.componentInstance;
     router = TestBed.inject(Router);
+    routerEvents$ = new Subject<RouterEvent>();
+    Object.defineProperty(router, 'url', {
+      get: () => '/block/nvmeof/gateways',
+      configurable: true
+    });
+    jest.spyOn(router, 'events', 'get').mockReturnValue(routerEvents$.asObservable());
   });
 
   it('should create', () => {
@@ -51,25 +104,59 @@ describe('NvmeofTabsComponent', () => {
     expect(component.activeTab).toBe(component.Tabs.gateways);
   });
 
+  it('should hide the shell on namespace create routes', () => {
+    jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces/create');
+    component.ngOnInit();
+    expect(component.showTabsShell).toBe(false);
+  });
+
+  it('should keep the shell visible on namespace list routes', () => {
+    jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces');
+    component.ngOnInit();
+    expect(component.showTabsShell).toBe(true);
+  });
+
+  it('should keep the shell visible on list routes with a secondary outlet', () => {
+    jest
+      .spyOn(router, 'url', 'get')
+      .mockReturnValue('/block/nvmeof/subsystems(modal:create)?group=default');
+    component.ngOnInit();
+    expect(component.showTabsShell).toBe(true);
+  });
+
+  it('should hide the shell when primary route is a create page with secondary outlet', () => {
+    jest
+      .spyOn(router, 'url', 'get')
+      .mockReturnValue('/block/nvmeof/subsystems/create(modal:create)?group=default');
+    component.ngOnInit();
+    expect(component.showTabsShell).toBe(false);
+  });
+
   it('should navigate to correct path on tab selection', () => {
     spyOn(router, 'navigate');
     component.onSelected(component.Tabs.subsystems);
-    expect(component.selectedTab).toBe(component.Tabs.subsystems);
-    expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof/subsystems']);
+    expect(component.activeTab).toBe(component.Tabs.subsystems);
+    expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'subsystems'], {
+      queryParamsHandling: 'preserve'
+    });
   });
 
   it('should navigate to gateways on selecting gateways tab', () => {
     spyOn(router, 'navigate');
     component.onSelected(component.Tabs.gateways);
-    expect(component.selectedTab).toBe(component.Tabs.gateways);
-    expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof/gateways']);
+    expect(component.activeTab).toBe(component.Tabs.gateways);
+    expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'gateways'], {
+      queryParamsHandling: 'preserve'
+    });
   });
 
   it('should navigate to namespaces on selecting namespaces tab', () => {
     spyOn(router, 'navigate');
     component.onSelected(component.Tabs.namespaces);
-    expect(component.selectedTab).toBe(component.Tabs.namespaces);
-    expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof/namespaces']);
+    expect(component.activeTab).toBe(component.Tabs.namespaces);
+    expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'namespaces'], {
+      queryParamsHandling: 'preserve'
+    });
   });
 
   it('should expose TABS enum via Tabs getter', () => {
@@ -78,4 +165,229 @@ describe('NvmeofTabsComponent', () => {
     expect(tabs.subsystems).toBe('subsystems');
     expect(tabs.namespaces).toBe('namespaces');
   });
+
+  describe('setup cards scenarios', () => {
+    it('should show setup cards', () => {
+      setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
+      component.ngOnInit();
+      expect(component.showSetupCards).toBe(true);
+    });
+
+    it('should detect subsystems and namespaces regardless of dropdown selection', () => {
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true });
+      component.ngOnInit();
+      setQueryParams({});
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(true);
+      expect(component.hasNamespaces).toBe(true);
+      expect(component.isAllConfigured).toBe(true);
+    });
+
+    it('scenario: no gateway groups â€” all steps pending', () => {
+      setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
+      component.ngOnInit();
+      expect(component.hasGatewayGroups).toBe(false);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+      expect(component.showSetupCards).toBe(true);
+    });
+
+    it('scenario: gateway groups exist, no subsystems across all groups â€” step 1 complete', () => {
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false });
+      component.ngOnInit();
+      setQueryParams({ group: 'grp1' });
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+    });
+
+    it('scenario: no subsystems in object response across all groups â€” step 1 complete', () => {
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false });
+      component.ngOnInit();
+      setQueryParams({ group: 'grp1' });
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+    });
+
+    it('scenario: subsystems in any group, no namespaces â€” steps 1 & 2 complete', () => {
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: false });
+      component.ngOnInit();
+      setQueryParams({ group: 'grp1' });
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(true);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+    });
+
+    it('scenario: all configured across any group â€” isAllConfigured is true', () => {
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true });
+      component.ngOnInit();
+      setQueryParams({ group: 'grp1' });
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(true);
+      expect(component.hasNamespaces).toBe(true);
+      expect(component.isAllConfigured).toBe(true);
+    });
+
+    it('scenario: all configured in object response across any group â€” isAllConfigured is true', () => {
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true });
+      component.ngOnInit();
+      setQueryParams({ group: 'grp1' });
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(true);
+      expect(component.hasNamespaces).toBe(true);
+      expect(component.isAllConfigured).toBe(true);
+    });
+
+    it('scenario: subsystems exist in grp2 only â€” step 2 still marked complete', () => {
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false });
+      component.ngOnInit();
+      setQueryParams({ group: 'grp1' });
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+    });
+
+    it('scenario: full config in grp2 only â€” onboarding complete regardless of selected group', () => {
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false });
+      component.ngOnInit();
+      setQueryParams({ group: 'grp1' });
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+    });
+
+    it('should trigger state reload when refresh$ emits', () => {
+      component.ngOnInit();
+
+      setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
+      emitRefresh();
+
+      expect(component.hasGatewayGroups).toBe(false);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+    });
+
+    it('should reload state on NavigationEnd (e.g. navigating back from a form)', () => {
+      component.ngOnInit();
+
+      setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
+
+      routerEvents$.next(
+        new NavigationEnd(1, '/block/nvmeof/namespaces', '/block/nvmeof/namespaces')
+      );
+
+      expect(component.hasGatewayGroups).toBe(false);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+    });
+
+    it('should show the initial gateway-only state after the last subsystem and namespace are removed', () => {
+      component.ngOnInit();
+
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false });
+
+      emitRefresh();
+
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+    });
+
+    it('should show the initial empty state after the last gateway is removed', () => {
+      component.ngOnInit();
+
+      setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
+
+      emitRefresh();
+
+      expect(component.hasGatewayGroups).toBe(false);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+    });
+
+    it('should refresh setup cards when the gateway list refreshes to empty', () => {
+      component.ngOnInit();
+
+      setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
+      emitRefresh();
+
+      expect(component.hasGatewayGroups).toBe(false);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+    });
+
+    it('should show gateway step complete after a gateway is created', () => {
+      setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
+      component.ngOnInit();
+
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false });
+
+      emitRefresh();
+
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(false);
+      expect(component.hasNamespaces).toBe(false);
+      expect(component.isAllConfigured).toBe(false);
+    });
+
+    it('should use fetchSetupState on refresh', () => {
+      setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
+      component.ngOnInit();
+      nvmeofServiceSpy.fetchSetupState.mockClear();
+
+      emitRefresh();
+
+      expect(nvmeofServiceSpy.fetchSetupState).toHaveBeenCalledTimes(1);
+    });
+
+    it('should render correct setup card messages after all gateway groups are removed', () => {
+      jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways');
+      setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true });
+      component.ngOnInit();
+
+      setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
+      emitRefresh();
+      fixture.detectChanges();
+
+      const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card');
+
+      expect(cardElements.length).toBe(3);
+      expect(cardElements[0].componentInstance.statusMessage).toBe(
+        'No gateway groups configured for this cluster yet.'
+      );
+      expect(cardElements[1].componentInstance.statusMessage).toBe('No gateway configured yet.');
+      expect(cardElements[2].componentInstance.statusMessage).toBe('No gateway configured yet.');
+    });
+
+    it('should render success setup card messages before gateway groups are removed', () => {
+      jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways');
+      component.ngOnInit();
+      emitRefresh();
+      fixture.detectChanges();
+      component.showSetupCards = true;
+      fixture.detectChanges();
+
+      const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card');
+
+      expect(cardElements[0].componentInstance.statusMessage).toBe(
+        'Gateway group configured successfully.'
+      );
+      expect(cardElements[1].componentInstance.statusMessage).toBe(
+        'Subsystem configured successfully.'
+      );
+      expect(cardElements[2].componentInstance.statusMessage).toBe(
+        'Namespaces mapped successfully.'
+      );
+    });
+  });
 });
index 8b74346db920a81721c7b75945dfdd184f0cf9a1..a42cd572a2c0a2ff559dbb9376d8eb91b0b413db 100644 (file)
@@ -1,5 +1,10 @@
-import { Component, OnInit } from '@angular/core';
-import { Router } from '@angular/router';
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
+import { Subscription, merge, of } from 'rxjs';
+import { filter, switchMap, tap } from 'rxjs/operators';
+
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NvmeofStateService } from '../nvmeof-state.service';
 
 const NVMEOF_PATH = 'block/nvmeof';
 
@@ -9,26 +14,85 @@ enum TABS {
   namespaces = 'namespaces'
 }
 
+const TAB_ROUTES = [
+  '/block/nvmeof/gateways',
+  '/block/nvmeof/subsystems',
+  '/block/nvmeof/namespaces'
+];
+
 @Component({
   selector: 'cd-nvmeof-tabs',
   templateUrl: './nvmeof-tabs.component.html',
   styleUrls: ['./nvmeof-tabs.component.scss'],
-  standalone: false
+  standalone: false,
+  providers: [NvmeofStateService]
 })
-export class NvmeofTabsComponent implements OnInit {
-  selectedTab: TABS;
+export class NvmeofTabsComponent implements OnInit, OnDestroy {
   activeTab: TABS = TABS.gateways;
+  showTabsShell = true;
+  showSetupCards = false;
+  hasGatewayGroups = false;
+  hasSubsystems = false;
+  hasNamespaces = false;
+  isAllConfigured = false;
+  private setupSubscription?: Subscription;
 
-  constructor(private router: Router) {}
+  constructor(
+    private router: Router,
+    private route: ActivatedRoute,
+    private nvmeofService: NvmeofService,
+    private nvmeofStateService: NvmeofStateService
+  ) {}
 
-  ngOnInit(): void {
-    const currentPath = this.router.url;
+  private updateActiveTab(currentPath: string): void {
     this.activeTab = Object.values(TABS).find((tab) => currentPath.includes(tab)) || TABS.gateways;
   }
 
+  private updateShellVisibility(currentPath: string): void {
+    const urlTree = this.router.parseUrl(currentPath);
+    const primarySegments =
+      urlTree.root.children['primary']?.segments.map((segment) => segment.path) ?? [];
+    const primaryPath = `/${primarySegments.join('/')}`;
+
+    this.showTabsShell = TAB_ROUTES.includes(primaryPath);
+  }
+
+  ngOnInit(): void {
+    this.updateActiveTab(this.router.url);
+    this.updateShellVisibility(this.router.url);
+
+    // Merge all trigger streams to prevent memory leaks and race conditions
+    this.setupSubscription = merge(
+      of(null), // Initial load
+      this.router.events.pipe(
+        filter((event): event is NavigationEnd => event instanceof NavigationEnd),
+        tap((event) => {
+          this.updateActiveTab(event.urlAfterRedirects);
+          this.updateShellVisibility(event.urlAfterRedirects);
+        })
+      ),
+      this.route.queryParams,
+      this.nvmeofStateService.refresh$
+    )
+      .pipe(switchMap(() => this.nvmeofService.fetchSetupState()))
+      .subscribe(({ hasGatewayGroups, hasSubsystems, hasNamespaces }) => {
+        this.hasGatewayGroups = hasGatewayGroups;
+        this.hasSubsystems = hasSubsystems;
+        this.hasNamespaces = hasNamespaces;
+        this.isAllConfigured = hasGatewayGroups && hasSubsystems && hasNamespaces;
+        this.showSetupCards = !this.isAllConfigured;
+      });
+  }
+
+  ngOnDestroy(): void {
+    this.setupSubscription?.unsubscribe();
+  }
+
   onSelected(tab: TABS) {
-    this.selectedTab = tab;
-    this.router.navigate([`${NVMEOF_PATH}/${tab}`]);
+    this.activeTab = tab;
+    this.router.navigate([NVMEOF_PATH, tab], {
+      queryParamsHandling: 'preserve'
+    });
   }
 
   public get Tabs(): typeof TABS {
index 77c31a6553d8ad198512a511f93bf39c5244050f..121299a9d30d22ed2394c772e07931ba97ae7737 100644 (file)
@@ -3,15 +3,21 @@ import { HttpClient } from '@angular/common/http';
 
 import _ from 'lodash';
 import { Observable, forkJoin, of as observableOf } from 'rxjs';
-import { catchError, map, mapTo, mergeMap } from 'rxjs/operators';
+import { catchError, map, mapTo, mergeMap, switchMap } from 'rxjs/operators';
 import { CephServiceSpec } from '../models/service.interface';
-import { ListenerItem } from '../models/nvmeof';
+import { ListenerItem, NvmeofSubsystem, NvmeofSubsystemNamespace } from '../models/nvmeof';
 import { HostService } from './host.service';
 import { OrchestratorService } from './orchestrator.service';
 import { HostStatus } from '../enum/host-status.enum';
 import { Host } from '../models/host.interface';
 import { OrchestratorStatus } from '../models/orchestrator.interface';
 
+export type SetupState = {
+  hasGatewayGroups: boolean;
+  hasSubsystems: boolean;
+  hasNamespaces: boolean;
+};
+
 export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512;
 
 export type GatewayGroup = CephServiceSpec;
@@ -173,6 +179,60 @@ export class NvmeofService {
     }, []);
   }
 
+  private normalizeListResponse<T>(response: unknown, key: string): T[] {
+    if (Array.isArray(response)) {
+      return response as T[];
+    }
+
+    const nested = (response as Record<string, T[]> | null)?.[key];
+    return Array.isArray(nested) ? nested : [];
+  }
+
+  fetchSetupState(): Observable<SetupState> {
+    return this.listGatewayGroups().pipe(
+      switchMap((gatewayGroups: CephServiceSpec[][]) => {
+        const rawGroups = Array.isArray(gatewayGroups[0]) ? gatewayGroups[0] : [];
+        const groups = rawGroups.filter((serviceSpec: CephServiceSpec) => serviceSpec?.spec?.group);
+        const hasGatewayGroups = groups.length > 0;
+
+        if (!hasGatewayGroups) {
+          return observableOf({
+            hasGatewayGroups: false,
+            hasSubsystems: false,
+            hasNamespaces: false
+          });
+        }
+
+        const firstGroupName = groups[0].spec.group;
+
+        return forkJoin({
+          subsystems: this.listSubsystems(firstGroupName).pipe(
+            map((resp: unknown) => this.normalizeListResponse<NvmeofSubsystem>(resp, 'subsystems')),
+            catchError(() => observableOf([]))
+          ),
+          namespaces: this.listNamespaces(firstGroupName).pipe(
+            map((resp: unknown) =>
+              this.normalizeListResponse<NvmeofSubsystemNamespace>(resp, 'namespaces')
+            ),
+            catchError(() => observableOf([]))
+          )
+        }).pipe(
+          map(({ subsystems, namespaces }) => ({
+            hasGatewayGroups,
+            hasSubsystems: subsystems.length > 0,
+            hasNamespaces: namespaces.length > 0
+          })),
+          catchError(() =>
+            observableOf({ hasGatewayGroups, hasSubsystems: false, hasNamespaces: false })
+          )
+        );
+      }),
+      catchError(() =>
+        observableOf({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false })
+      )
+    );
+  }
+
   // Gateway groups
   listGatewayGroups() {
     return this.http.get<CephServiceSpec[][]>(`${API_PATH}/gateway/group`);
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html
new file mode 100644 (file)
index 0000000..d2cd4c2
--- /dev/null
@@ -0,0 +1,20 @@
+<div class="setup-step-card" [class.setup-step-card--loading]="isLoading">
+  <div class="setup-step-card__step-row">
+    <span class="cds--type-heading-compact-01">{{ stepNumber }}.</span>
+    <span class="cds--type-heading-compact-01">{{ title }}</span>
+  </div>
+  <p class="cds--type-label-01">
+    {{ description }}
+  </p>
+  <div class="setup-step-card__info">
+    @if (isLoading) {
+    <cd-loading-panel></cd-loading-panel>
+    } @else if (isConfigured) {
+    <cd-icon type="success"></cd-icon>
+    <span class="cds--type-label-01">{{ statusMessage }}</span>
+    } @else {
+    <cd-icon type="infoCircle"></cd-icon>
+    <span class="cds--type-label-01">{{ statusMessage }}</span>
+    }
+  </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss
new file mode 100644 (file)
index 0000000..5becfaf
--- /dev/null
@@ -0,0 +1,24 @@
+.setup-step-card {
+  display: flex;
+  flex-direction: column;
+  gap: var(--cds-spacing-03);
+  height: 100%;
+
+  &--loading {
+    opacity: 0.7;
+    pointer-events: none;
+  }
+
+  &__step-row {
+    display: flex;
+    align-items: baseline;
+    gap: var(--cds-spacing-03);
+  }
+
+  &__info {
+    display: flex;
+    align-items: center;
+    margin-top: auto;
+    gap: var(--cds-spacing-03);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts
new file mode 100644 (file)
index 0000000..0414795
--- /dev/null
@@ -0,0 +1,134 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { SetupStepCardComponent } from './setup-step-card.component';
+
+describe('SetupStepCardComponent', () => {
+  let component: SetupStepCardComponent;
+  let fixture: ComponentFixture<SetupStepCardComponent>;
+
+  const STEP_TITLE = 'Example step';
+  const STEP_DESCRIPTION = 'Example step description.';
+  const SUCCESS_MESSAGE = 'Example configured successfully.';
+  const INFO_MESSAGE = 'Example not configured yet.';
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [SetupStepCardComponent]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(SetupStepCardComponent);
+    component = fixture.componentInstance;
+    component.stepNumber = 1;
+    component.title = STEP_TITLE;
+    component.description = STEP_DESCRIPTION;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should render step number, title, and description', () => {
+    const element = fixture.nativeElement;
+    expect(element.textContent).toContain('1.');
+    expect(element.textContent).toContain(STEP_TITLE);
+    expect(element.textContent).toContain(STEP_DESCRIPTION);
+  });
+
+  describe('statusMessage', () => {
+    it('should return successMessage when configured', () => {
+      component.isConfigured = true;
+      component.successMessage = SUCCESS_MESSAGE;
+
+      expect(component.statusMessage).toBe(SUCCESS_MESSAGE);
+    });
+
+    it('should return infoMessage when not configured', () => {
+      component.isConfigured = false;
+      component.infoMessage = INFO_MESSAGE;
+
+      expect(component.statusMessage).toBe(INFO_MESSAGE);
+    });
+
+    it('should return default success text when configured without successMessage', () => {
+      component.isConfigured = true;
+      component.successMessage = undefined;
+
+      expect(component.statusMessage).toBe('Configured successfully.');
+    });
+
+    it('should return default info text when not configured without infoMessage', () => {
+      component.isConfigured = false;
+      component.infoMessage = undefined;
+
+      expect(component.statusMessage).toBe('Not configured yet.');
+    });
+
+    it('should prefer successMessage over infoMessage when configured', () => {
+      component.isConfigured = true;
+      component.successMessage = SUCCESS_MESSAGE;
+      component.infoMessage = INFO_MESSAGE;
+
+      expect(component.statusMessage).toBe(SUCCESS_MESSAGE);
+    });
+  });
+
+  describe('template', () => {
+    it('should render the status message when not configured', () => {
+      component.isConfigured = false;
+      component.infoMessage = INFO_MESSAGE;
+      fixture.detectChanges();
+
+      const message = fixture.nativeElement.querySelector('.setup-step-card__info span');
+      expect(message.textContent.trim()).toBe(INFO_MESSAGE);
+    });
+
+    it('should render the status message when configured', () => {
+      component.isConfigured = true;
+      component.successMessage = SUCCESS_MESSAGE;
+      fixture.detectChanges();
+
+      const message = fixture.nativeElement.querySelector('.setup-step-card__info span');
+      expect(message.textContent.trim()).toBe(SUCCESS_MESSAGE);
+    });
+
+    it('should render a success icon when configured', () => {
+      component.isConfigured = true;
+      component.successMessage = SUCCESS_MESSAGE;
+      fixture.detectChanges();
+
+      const icon = fixture.nativeElement.querySelector('cd-icon');
+      expect(icon.getAttribute('ng-reflect-type')).toBe('success');
+    });
+
+    it('should render an info icon when not configured', () => {
+      component.isConfigured = false;
+      component.infoMessage = INFO_MESSAGE;
+      fixture.detectChanges();
+
+      const icon = fixture.nativeElement.querySelector('cd-icon');
+      expect(icon.getAttribute('ng-reflect-type')).toBe('infoCircle');
+    });
+
+    it('should render loading panel when isLoading is true', () => {
+      component.isLoading = true;
+      fixture.detectChanges();
+
+      const loadingPanel = fixture.nativeElement.querySelector('cd-loading-panel');
+      expect(loadingPanel).toBeTruthy();
+    });
+
+    it('should return "Loading..." as statusMessage when isLoading is true', () => {
+      component.isLoading = true;
+      expect(component.statusMessage).toBe('Loading...');
+    });
+
+    it('should apply loading class when isLoading is true', () => {
+      component.isLoading = true;
+      fixture.detectChanges();
+
+      const card = fixture.nativeElement.querySelector('.setup-step-card');
+      expect(card.classList.contains('setup-step-card--loading')).toBe(true);
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts
new file mode 100644 (file)
index 0000000..c77b3b5
--- /dev/null
@@ -0,0 +1,33 @@
+import { Component, Input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ComponentsModule } from '../components.module';
+
+@Component({
+  selector: 'cd-setup-step-card',
+  standalone: true,
+  imports: [CommonModule, ComponentsModule],
+  templateUrl: './setup-step-card.component.html',
+  styleUrls: ['./setup-step-card.component.scss']
+})
+export class SetupStepCardComponent {
+  @Input() stepNumber!: number;
+  @Input() title!: string;
+  @Input() description!: string;
+  @Input() isConfigured: boolean = false;
+  @Input() isLoading: boolean = false;
+  @Input() successMessage?: string;
+  @Input() infoMessage?: string;
+
+  get statusMessage(): string {
+    if (this.isLoading) {
+      return $localize`Loading...`;
+    }
+    if (this.isConfigured && this.successMessage) {
+      return this.successMessage;
+    }
+    if (!this.isConfigured && this.infoMessage) {
+      return this.infoMessage;
+    }
+    return this.isConfigured ? $localize`Configured successfully.` : $localize`Not configured yet.`;
+  }
+}
index f5a7297b1676fd1593dc554f80156b45680e950a..f76c642986f38a9edea39360efb4b64ff98946a2 100644 (file)
@@ -41,6 +41,9 @@
     </cds-table-toolbar-actions>
     <!-- end batch actions -->
     <cds-table-toolbar-content>
+      <!-- gateway group / custom filter slot -->
+      <ng-content select=".table-filter"></ng-content>
+      <!-- end custom filter slot -->
       <!-- search -->
       <cds-table-toolbar-search *ngIf="searchField"
                                 [expandable]="false"
              let-column="data.column"
              let-row="data.row"
              let-value="data.value">
-  <cds-inline-loading *ngIf="row.cdExecuting"
-                      state="active"></cds-inline-loading>
-  <span [ngClass]="column?.customTemplateConfig?.valueClass">
+  @if (row.cdExecuting) {
+    <cds-inline-loading></cds-inline-loading>
+  }
+  <span [ngClass]="column?.customTemplateConfig?.valueClass || ''">
     {{ value }}
   </span>
-  <span *ngIf="row.cdExecuting"
-        [ngClass]="column?.customTemplateConfig?.executingClass ? column?.customTemplateConfig.executingClass : 'text-muted italic'">({{ row.cdExecuting }})</span>
+  @if (row.cdExecuting) {
+    <span [ngClass]="column?.customTemplateConfig?.executingClass ? column?.customTemplateConfig.executingClass : 'text-muted italic'">({{ row.cdExecuting }})</span>
+  }
 </ng-template>
 <ng-template #classAddingTpl
              let-value="data.value">