]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Implement NVMe-oF Gateway Group Resource Overview Page 69439/head
authorpujashahu <pshahu@redhat.com>
Fri, 12 Jun 2026 22:05:13 +0000 (03:35 +0530)
committerpujashahu <pshahu@redhat.com>
Thu, 2 Jul 2026 10:19:38 +0000 (15:49 +0530)
Fixes: https://tracker.ceph.com/issues/77383
Signed-off-by: pujaoshahu <pshahu@redhat.com>
27 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html
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.scss
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-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.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/core/layouts/workbench-layout/workbench-layout.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss

index 135ec9134b4a5feb816512f8ce362f5d0e06d942..368a7de95b71de598041a50992f43d9b8d3bf511 100644 (file)
@@ -96,6 +96,7 @@ import Datastore from '@carbon/icons/es/datastore/16';
 import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component';
 import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component';
 import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component';
+// import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
 import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver';
 import { NvmeofGatewayNodeMode } from '~/app/shared/models/nvmeof';
 import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component';
@@ -386,7 +387,7 @@ const routes: Routes = [
               {
                 path: 'nodes',
                 component: NvmeofGatewayNodeComponent,
-                data: { breadcrumbs: $localize`Gateway nodes`, mode: NvmeofGatewayNodeMode.DETAILS }
+                data: { breadcrumbs: $localize`Overview`, mode: NvmeofGatewayNodeMode.DETAILS }
               },
               {
                 path: 'subsystems',
index 9d8edd73999e0e1e1aa7ca9f8ebee2d8fc1d44da..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 (file)
@@ -1,8 +0,0 @@
-.breadcrumbs--padding {
-  padding-left: 0 !important;
-}
-
-.cds--breadcrumb {
-  margin-top: 0;
-  padding: var(--cds-spacing-05);
-}
index f5f13e38442c504fb04f5b904e71c612a807c232..55b8056f942d578c0d3c112849fcd6816bfbd2cb 100644 (file)
@@ -24,7 +24,7 @@ export class NvmeGatewayViewComponent implements OnInit {
       this.groupName = pm.get('group') ?? '';
       this.sidebarItems = [
         {
-          label: $localize`Gateway nodes`,
+          label: $localize`Overview`,
           route: [this.basePath, this.groupName, 'nodes'],
           routerLinkActiveOptions: { exact: true }
         },
index d3b43ababa96efa06b04612ed8cfffd1f0afb435..c04056a900d24b99eb9b925d0a8154210870fadf 100644 (file)
@@ -4,7 +4,7 @@
      [fullWidth]="true">
 <div cdsCol
      [columnNumbers]="{sm: 4, md: 8}">
-  <div class="form-item"
+  <div class="form-item cds-mt-5"
        cdsRow>
     <cds-combo-box
         type="single"
index 40ffb638bfa81f16a5b215dbac206da4a016cf18..8d4007806bcf981c3ff9dcb8482420e63525f78d 100644 (file)
     i18n-emptyStateTitle
     emptyStateMessage="Set up your first gateway group to start using NVMe over Fabrics. This will allow you to create high-performance block storage with NVMe/TCP protocol."
     i18n-emptyStateMessage>
-  <cd-table-actions class="table-actions"
-                    [permission]="permission"
-                    [selection]="selection"
-                    [tableActions]="tableActions">
-  </cd-table-actions>
+
+    <div class="table-actions">
+      <cd-table-actions [permission]="permission"
+                        [selection]="selection"
+                        class="btn-group"
+                        [tableActions]="tableActions">
+      </cd-table-actions>
+    </div>
   </cd-table>
 </ng-container>
 
@@ -37,7 +40,7 @@
 <ng-template #gatewayStatusTpl
              let-gateway="data.value">
   <div [cdsStack]="'horizontal'"
-       gap="4">
+       [gap]="4">
 
     @if (gateway.running > 0) {
     <span [ngClass]="gateway.error > 0 ? 'cds-mr-3' : ''">
index fe2100fc90ba7bfd1930591b49fe741dbaa42106..dcaced4aaaa9d6aef753e4099e57a265d5c4f6b0 100644 (file)
@@ -6,7 +6,6 @@ import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 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';
@@ -34,10 +33,6 @@ describe('NvmeofGatewayGroupComponent', () => {
       declarations: [NvmeofGatewayGroupComponent],
       providers: [
         { provide: NvmeofService, useValue: nvmeofServiceSpy },
-        {
-          provide: Router,
-          useValue: { navigate: jest.fn() }
-        },
         {
           provide: ModalCdsService,
           useValue: { show: jest.fn() }
@@ -267,6 +262,25 @@ describe('NvmeofGatewayGroupComponent', () => {
     });
   });
 
+  describe('View details action', () => {
+    it('should use routerLink and navigate to the resource page for the selected group', () => {
+      component.selection.first = jest.fn().mockReturnValue({ name: 'default' });
+      const viewAction = component.tableActions.find((a) => a.name === 'View details');
+      expect(viewAction).toBeTruthy();
+      expect(viewAction!.click).toBeUndefined();
+      const link = (viewAction!.routerLink as Function)();
+      expect(link).toBe('/block/nvmeof/gateways/view/default');
+    });
+
+    it('should set canBePrimary true for single selection only', () => {
+      const viewAction = component.tableActions.find((a) => a.name === 'View details');
+      const single = { hasSingleSelection: true } as any;
+      const multi = { hasSingleSelection: false } as any;
+      expect((viewAction!.canBePrimary as Function)(single)).toBe(true);
+      expect((viewAction!.canBePrimary as Function)(multi)).toBe(false);
+    });
+  });
+
   describe('Delete Flow with/without Subsystems', () => {
     let mockGroup: any;
 
index 5d622a0bdef561f86e63a6bf156b84eca329f5ad..b59f235e8affaa80c3d69780f1464c54d33c03f1 100644 (file)
@@ -20,7 +20,7 @@ import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { Permission } from '~/app/shared/models/permissions';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { Icons, IconSize } from '~/app/shared/enum/icons.enum';
-import { NvmeofGatewayGroup, NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
 import { CephServiceSpec } from '~/app/shared/models/service.interface';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { CephServiceService } from '~/app/shared/api/ceph-service.service';
@@ -48,31 +48,31 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
   private destroy$ = new Subject<void>();
 
   @ViewChild(TableComponent, { static: true })
-  table: TableComponent;
+  table!: TableComponent;
 
   @ViewChild('dateTpl', { static: true })
-  dateTpl: TemplateRef<any>;
+  dateTpl!: TemplateRef<any>;
 
   @ViewChild('customTableItemTemplate', { static: true })
-  customTableItemTemplate: TemplateRef<any>;
+  customTableItemTemplate!: TemplateRef<any>;
 
   @ViewChild('deleteTpl', { static: true })
-  deleteTpl: TemplateRef<any>;
+  deleteTpl!: TemplateRef<any>;
 
   @ViewChild('gatewayStatusTpl', { static: true })
-  gatewayStatusTpl: TemplateRef<any>;
+  gatewayStatusTpl!: TemplateRef<any>;
 
-  permission: Permission;
-  tableActions: CdTableAction[];
+  permission!: Permission;
+  tableActions: CdTableAction[] = [];
   nodesAvailable = false;
   columns: CdTableColumn[] = [];
   selection: CdTableSelection = new CdTableSelection();
-  gatewayGroup$: Observable<CephServiceSpec[]>;
+  gatewayGroup$: Observable<CephServiceSpec[]> = of([]);
   subject = new BehaviorSubject<CephServiceSpec[]>([]);
-  context: CdTableFetchDataContext;
-  gatewayGroupName: string;
-  subsystemCount: number;
-  gatewayCount: number;
+  context!: CdTableFetchDataContext;
+  gatewayGroupName = '';
+  subsystemCount = 0;
+  gatewayCount = 0;
   private lastGroupCount = 0;
 
   viewUrl = `/${BASE_URL}/view`;
@@ -130,9 +130,9 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
     const viewAction: CdTableAction = {
       permission: 'read',
       icon: Icons.eye,
-      click: () => this.getViewDetails(),
+      routerLink: () => `${this.viewUrl}/${this.selection.first()?.name}`,
       name: $localize`View details`,
-      canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+      canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection
     };
 
     const deleteAction: CdTableAction = {
@@ -154,7 +154,7 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
               return of([]);
             }
             return forkJoin(
-              groups.map((group: NvmeofGatewayGroup) => {
+              groups.map((group: CephServiceSpec) => {
                 const isRunning = (group.status?.running ?? 0) > 0;
                 const subsystemsObservable = isRunning
                   ? this.nvmeofService.listSubsystems(group.spec.group).pipe(
@@ -226,7 +226,7 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
     this.nvmeofService
       .listSubsystems(group)
       .pipe(catchError(() => of([])))
-      .subscribe((subsystems: NvmeofSubsystem[]) => {
+      .subscribe((subsystems: any) => {
         let subsList: NvmeofSubsystem[] = [];
         if (subsystems) {
           const rawList = Array.isArray(subsystems) ? subsystems : [subsystems];
@@ -312,7 +312,6 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
       }
     );
   }
-
   getViewDetails() {
     const selectedGroup = this.selection.first();
     if (!selectedGroup) {
index 497c57974541dbd8156b46018ab05f36b8e3e658..ad98498ca6bf869ff3088c8b29e8cfe5be4fc8bb 100644 (file)
@@ -1,90 +1,68 @@
-<cds-modal
-  [open]="open"
-  (overlaySelected)="closeModal()"
-  [size]="'lg'">
-  <cds-modal-header
-    (closeSelect)="closeModal()">
-    <h4
-      class="cds--type-heading-04 cds-ml-3"
-      i18n>
+<cds-modal [open]="open" 
+           (overlaySelected)="closeModal()" 
+           [size]="'md'">
+  <cds-modal-header (closeSelect)="closeModal()">
+    <h4 class="cds--type-heading-04" 
+        i18n>
       Add gateway nodes
     </h4>
-    <p
-      class="cds--type-body-compact-01 cds-ml-3"
-      i18n>
+    <p class="cds--type-body-01" 
+       i18n>
       Select NVMe-oF gateway nodes to associate with this gateway group.
     </p>
   </cds-modal-header>
   <section cdsModalContent>
 
-    <div class="cds-mt-5">
-      <div
-        cdsStack="vertical"
-        gap="1">
-        <div
-          class="cds--type-heading-01"
-          i18n>
-          Select gateway nodes
-        </div>
-        <div
-          class="cds--type-label-01"
-          i18n>
-          Nodes to run NVMe-oF target service
-        </div>
-      </div>
+    <div class="cds-ml-5 cds-mt-5">
+      <p class="cds--type-heading-01" i18n>Select gateway nodes</p>
+      <p class="cds--type-label-01" i18n>Nodes to run NVMe-oF target pods/services</p>
+    </div>
 
-      <cd-table
-        (fetchData)="getHosts($event)"
-        selectionType="multiClick"
-        [searchableObjects]="true"
-        [data]="hosts"
-        [columns]="columns"
-        (updateSelection)="updateSelection($event)"
-        [autoReload]="false">
-      </cd-table>
+    <!-- Table on white background -->
+    <cd-table (fetchData)="getHosts($event)" 
+              selectionType="multiClick"
+              [searchableObjects]="true"
+              [data]="hosts"
+              [columns]="columns"
+              (updateSelection)="updateSelection($event)" 
+              [autoReload]="false">
+    </cd-table>
 
-      <ng-template
-        #addrTemplate
-        let-value="data.value">
-        <span>{{ value || '-' }}</span>
-      </ng-template>
+    <ng-template #addrTemplate 
+                 let-value="data.value">
+      <span>{{ value || '-' }}</span>
+    </ng-template>
 
-      <ng-template
-        #statusTemplate
-        let-value="data.value"
-        let-row="data.row">
-        <div
-          class="status-cell"
-          cdsStack="horizontal"
-          gap="3">
+    <ng-template #statusTemplate 
+                 let-value="data.value" 
+                 let-row="data.row">
+      <div class="status-cell" 
+           cdsStack="horizontal"
+           gap="3">
         @if (value === HostStatus.AVAILABLE) {
-          <cd-icon type="success"></cd-icon>
+        <cd-icon type="success"></cd-icon>
         }
         <span>{{ value | titlecase }}</span>
-        </div>
-      </ng-template>
+      </div>
+    </ng-template>
 
-      <ng-template
-        #labelsTemplate
-        let-value="data.value">
+    <ng-template #labelsTemplate 
+                 let-value="data.value">
       @if (value && value.length > 0) {
-        <cds-tag
-          *ngFor="let label of value"
-          class="tag tag-dark">{{ label }}</cds-tag>
+      <cds-tag *ngFor="let label of value" 
+                class="tag tag-dark">{{ label }}</cds-tag>
       } @else {
-        <span>-</span>
+      <span>-</span>
       }
-      </ng-template>
+    </ng-template>
 
-    </div>
   </section>
-  <cd-form-button-panel
-    [modalForm]="true"
-    [showSubmit]="true"
-    submitText="Add"
-    i18n-submitText
-    [disabled]="selection.selected.length === 0"
-    (submitActionEvent)="onSubmit()"
-    (backActionEvent)="closeModal()">
+  <cd-form-button-panel [modalForm]="true" 
+                        [showSubmit]="true" 
+                        submitText="Add" 
+                        i18n-submitText
+                        [disabled]="selection.selected.length === 0" 
+                        (submitActionEvent)="onSubmit()" 
+                        (backActionEvent)="closeModal()">
   </cd-form-button-panel>
-</cds-modal>
+</cds-modal>
\ No newline at end of file
index dbb535acfd380b3698d1ad4288d1cb63e0724c20..f93358bb00d94add24724d6b0d853a4c0b2410a1 100644 (file)
@@ -1,7 +1,22 @@
-cds-modal-header {
-  border-bottom: 1px solid var(--cds-ui-03, #e0e0e0);
-}
+cd-nvmeof-gateway-node-add-modal {
+  .cds--modal-container {
+    height: 80vh;
+    max-height: 80vh;
+  }
+
+  .cds--modal-header {
+    border-bottom: none;
+  }
+
+  .cds--modal-content {
+    background-color: var(--cds-background);
+    padding: 0;
+  }
 
-.status-cell {
-  align-items: center;
+  cd-table {
+    display: block;
+    background-color: var(--cds-layer);
+    border: 1px solid var(--cds-border-subtle-01);
+    margin: 0 var(--cds-spacing-05) var(--cds-spacing-05);
+  }
 }
index 6c447d2e244f701ac7009e4b10a930617c1dcbfa..ee8d64dfa3a3f7d319cf2ab37b455b5c28404f93 100644 (file)
@@ -5,7 +5,8 @@ import {
   ViewChild,
   TemplateRef,
   OnDestroy,
-  Inject
+  Inject,
+  ViewEncapsulation
 } from '@angular/core';
 import { Subscription } from 'rxjs';
 
@@ -26,7 +27,8 @@ import { TaskMessageService } from '~/app/shared/services/task-message.service';
   selector: 'cd-nvmeof-gateway-node-add-modal',
   templateUrl: './nvmeof-gateway-node-add-modal.component.html',
   styleUrls: ['./nvmeof-gateway-node-add-modal.component.scss'],
-  standalone: false
+  standalone: false,
+  encapsulation: ViewEncapsulation.None
 })
 export class NvmeofGatewayNodeAddModalComponent extends CdForm implements OnInit, OnDestroy {
   hosts: Host[] = [];
index 678e0cd5f63466cc9f0d9d4a8a8d404c7eb24fb2..7e420bfe86ee08aba48d183348fe7a88fcf7d13d 100644 (file)
@@ -1,77 +1,70 @@
-<div>
-  <cd-table
-    #table
-    [data]="hosts"
-    [columns]="columns"
-    columnMode="flex"
-    (fetchData)="getHosts($event)"
-    [selectionType]="selectionType"
-    [searchableObjects]="true"
-    [serverSide]="false"
-    [maxLimit]="25"
-    identifier="hostname"
-    forceIdentifier="true"
-    (updateSelection)="updateSelection($event)"
-    emptyStateTitle="No nodes available"
-    i18n-emptyStateTitle
-    emptyStateMessage="Add your first gateway node to start using NVMe over Fabrics. Nodes provide the resources required to expose NVMe/TCP block storage."
-    i18n-emptyStateMessage
-  >
-    <cd-table-actions
-      class="table-actions"
-      [permission]="permission"
-      [selection]="selection"
+<div class="content-theme">
+  @if (mode === 'details' && groupName) {
+  <cd-details-card cardTitle="Details" i18n-cardTitle 
+                   [details]="gatewayDetails" 
+                   class="cds-mb-5">
+  </cd-details-card>
+  }
+
+  @if (mode === 'details') {
+  <div class="cds-mb-5">
+    <h2 class="cds--type-heading-03 cds-mt-5" i18n>Gateway nodes</h2>
+    <p class="cds--type-body-compact-01" i18n>Nodes running NVMe-oF target pods/services</p>
+  </div>
+  }
+  <cd-table #table 
+            [data]="hosts" 
+            [columns]="columns" 
+            columnMode="flex" 
+            (fetchData)="getHosts($event)"
+            [selectionType]="selectionType" 
+            [searchableObjects]="true" 
+            [serverSide]="false" 
+            [maxLimit]="25"
+            identifier="hostname" 
+            [forceIdentifier]="true" 
+            (updateSelection)="updateSelection($event)"
+            emptyStateTitle="No nodes available" i18n-emptyStateTitle
+            emptyStateMessage="Add your first gateway node to start using NVMe over Fabrics. Nodes provide the resources required to expose NVMe/TCP block storage."
+            i18n-emptyStateMessage>
+    <cd-table-actions class="table-actions" 
+                      [permission]="permission" 
+                      [selection]="selection"
       [tableActions]="tableActions">
     </cd-table-actions>
   </cd-table>
 </div>
 
-<ng-template
-  #hostNameTpl
-  let-value="data.value"
->
+<ng-template #hostNameTpl 
+             let-value="data.value">
   <span class="cds-ml-2">{{ value }}</span>
 </ng-template>
 
-<ng-template
-  #addrTpl
-  let-value="data.value"
->
+<ng-template #addrTpl let-value="data.value">
   <span>{{ value || '-' }}</span>
 </ng-template>
 
-<ng-template
-  #statusTpl
-  let-value="data.value"
-  let-row="data.row"
->
-@if (value) {
-  <div
-    [cdsStack]="'horizontal'"
-    gap="4"
-  >
-  @if (value === HostStatus.AVAILABLE || value === HostStatus.RUNNING) {
+<ng-template #statusTpl 
+             let-value="data.value" 
+             let-row="data.row">
+  @if (value) {
+  <div [cdsStack]="'horizontal'" [gap]="4">
+    @if (value === HostStatus.AVAILABLE || value === HostStatus.RUNNING) {
     <cd-icon type="success"></cd-icon>
-  } @else {
+    } @else {
     <cd-icon type="error"></cd-icon>
-  }
-  <span class="cds-ml-3">{{ value | titlecase }}</span>
+    }
+    <span class="cds-ml-3">{{ value | titlecase }}</span>
   </div>
-} @else {
+  } @else {
   <span>-</span>
-}
+  }
 </ng-template>
 
-<ng-template
-  #labelsTpl
-  let-value="data.value"
->
-@if (value && value.length > 0) {
-  <cds-tag *ngFor="let label of value"
-           class="tag tag-dark">{{ label }}</cds-tag>
-} @else {
+<ng-template #labelsTpl let-value="data.value">
+  @if (value && value.length > 0) {
+  <cds-tag *ngFor="let label of value" class="tag tag-dark">{{ label }}</cds-tag>
+  } @else {
   <span>-</span>
-}
-</ng-template>
-
-
+  }
+</ng-template>
\ No newline at end of file
index 2de89e3a478400ff7dd608f9a55ad9128dd1100d..b0595304e7445be9ec9204466b5ec7ef242179fd 100644 (file)
@@ -38,6 +38,7 @@ import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { DetailItem } from '~/app/shared/components/details-card/details-card.component';
 
 @Component({
   selector: 'cd-nvmeof-gateway-node',
@@ -70,6 +71,7 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
   usedHostnames: Set<string> = new Set();
   serviceSpec: CephServiceSpec | undefined;
   hasAvailableHosts = false;
+  gatewayDetails: DetailItem[] = [];
 
   permission: Permission;
   columns: CdTableColumn[] = [];
@@ -349,9 +351,39 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
       } else {
         this.hosts = [];
       }
+
+      this.gatewayDetails = this.buildGatewayDetails(this.serviceSpec, this.hosts.length);
     }
 
     this.count = this.hosts.length;
     this.hostsLoaded.emit(this.count);
   }
+
+  private buildGatewayDetails(
+    serviceSpec: CephServiceSpec,
+    gatewayNodeCount: number
+  ): DetailItem[] {
+    return [
+      {
+        label: $localize`Gateway name`,
+        value: serviceSpec.spec?.group || this.groupName || '-'
+      },
+      {
+        label: $localize`Gateway nodes`,
+        value: gatewayNodeCount
+      },
+      {
+        label: $localize`Encryption`,
+        value: serviceSpec.spec?.enable_auth ? $localize`Enabled` : $localize`Disabled`,
+        type: 'status',
+        statusIcon: serviceSpec.spec?.enable_auth ? 'success' : 'error'
+      },
+      {
+        label: $localize`mTLS`,
+        value: $localize`Disabled`,
+        type: 'status',
+        statusIcon: 'error'
+      }
+    ];
+  }
 }
index b482607b3718b4ba17246d496133c03bdb77f594..bdac48c78b206e5d96d11e6cb003471828ae43e5 100644 (file)
         </div>
       </div>
 
-      <!-- CDS Input (shown when checkbox is checked) -->
+      <!-- Encryption Configuration (shown when checkbox is checked) -->
       @if (groupForm.controls.enableEncryption.value) {
       <div cdsRow
            class="form-item">
         <div cdsCol>
           <cds-textarea-label
-            labelInputID="cdsInput"
+            labelInputID="encryptionConfig"
             helperText="Provide the encryption configuration details."
+            cdRequiredField="Encryption configuration"
             i18n
-            i18n-helperText>Encryption configuration
+            i18n-helperText
+            [invalid]="encryptionConfigRef.isInvalid">Encryption configuration
             <textarea cdsTextArea
-                      id="cdsInput"
+                      id="encryptionConfig"
                       cdValidate
-                      #encryptionConfig="cdValidate"
-                      [invalid]="encryptionConfig.invalid"
-                      [invalidText]="encryptionConfigError"
+                      #encryptionConfigRef="cdValidate"
+                      [invalid]="encryptionConfigRef.isInvalid"
                       formControlName="encryptionConfig"
                       cols="100"
                       rows="4">
             </textarea>
           </cds-textarea-label>
+          <span
+            class="invalid-feedback"
+            *ngIf="groupForm.showError('encryptionConfig', formDir, 'required')"
+            i18n>This field is required.</span>
         </div>
       </div>
-      <ng-template #encryptionConfigError>
-        <span *ngIf="groupForm.controls.encryptionConfig.hasError('required')"
-              class="invalid-feedback"
-              i18n>This field is required.</span>
-      </ng-template>
       }
       <!-- Target Nodes Selection -->
       <div
index c1fb56b251e329548d3ed3dd8c79ceaa35125127..f55ac8f735785a2f89561aaa16469f676a7d1c37 100644 (file)
@@ -21,11 +21,11 @@ import { NvmeofService } from '~/app/shared/api/nvmeof.service';
   standalone: false
 })
 export class NvmeofGroupFormComponent extends CdForm implements OnInit {
-  @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent: NvmeofGatewayNodeComponent;
+  @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent!: NvmeofGatewayNodeComponent;
 
   permission: Permission;
-  groupForm: CdFormGroup;
-  action: string;
+  groupForm!: CdFormGroup;
+  action!: string;
   resource: string;
   group: string;
   pageURL: string;
index aab200d7a1cdb2e973186084cd0b7e7ae59b7d15..48b6326e937ba18807da2890613de8c53f947c90 100644 (file)
@@ -22,7 +22,6 @@
         <cds-text-label
           i18n
           [invalid]="nqnRef.isInvalid"
-          [invalidText]="nqnInvalidTemplate"
           helperText="A unique identifier for the subsystem."
           i18n-helperText>
           Subsystem NQN (NVMe Qualified Name)
                  formControlName="nqn"
                  [invalid]="nqnRef.isInvalid">
         </cds-text-label>
+        @if (nqnRef.isInvalid) {
+          @for (err of formGroup.get('nqn')?.errors | keyvalue; track err.key) {
+            <span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+          }
+        }
       </div>
       <div cdsRow
            class="form-item">
index f848d67c66ea32fa5b2083f150542a4dbe21ca52..e62934ca8d44830170cb87820114cdee2d7dbd29 100644 (file)
@@ -160,4 +160,12 @@ describe('NvmeofSubsystemsComponent', () => {
   it('should set first group as default initially', () => {
     expect(component.group).toBe('default');
   });
+
+  it('should clear selected group and stop fetching subsystems', () => {
+    component.group = 'default';
+
+    component.onGroupChange(null);
+
+    expect(component.group).toBeNull();
+  });
 });
index 7eae41be97ccac2e3f8c523ef42a752495fe32ca..e32f41ab60504ae5c6eeb8e374dc83a1e1083fcb 100644 (file)
@@ -31,7 +31,7 @@
     padding-right: 0;
 
     .breadcrumbs--padding {
-      padding-left: var(--cds-spacing-07);
+      padding-left: var(--cds-spacing-05);
     }
   }
 }
index 5fbd0930b45f01fd50c3e72e508d4c49dd608165..15452fcf264ff9ce775bfc695aeb2d07897094cc 100644 (file)
@@ -137,10 +137,12 @@ import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.componen
 import { PageHeaderComponent } from './page-header/page-header.component';
 import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.component';
 import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.component';
+import { ProductiveCardComponent } from './productive-card/productive-card.component';
 
 @NgModule({
   imports: [
     CommonModule,
+    ProductiveCardComponent,
     FormsModule,
     ReactiveFormsModule,
     NgbAlertModule,
@@ -279,7 +281,8 @@ import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.com
     TearsheetStepComponent,
     PageHeaderComponent,
     SidebarLayoutComponent,
-    NumberWithUnitComponent
+    NumberWithUnitComponent,
+    ProductiveCardComponent
   ]
 })
 export class ComponentsModule {
index 9a402f60c5934170914c8654f2abe286aa98e486..dd302459d61d6173c3fb6a3cb11579c879ff414e 100644 (file)
@@ -1,14 +1,38 @@
-<cds-tile>
-@if (cardTitle) {
-  <div class="details-card-header">
-    <span class="cds--type-heading-03"
-          i18n>{{ cardTitle }}</span>
-  </div>
-}
+<cd-productive-card>
+  <ng-template #header>
+    @if (cardTitle) {
+    <h3 class="cds--type-heading-03" i18n>{{ cardTitle }}</h3>
+    }
+  </ng-template>
 
-  <div class="details-card-body">
-    <div cdsRow>
-      <ng-content select=".details-body-content"></ng-content>
+  <div [cdsStack]="'vertical'" 
+       [gap]="0">
+    @if (details && details.length > 0) {
+    <div [cdsStack]="'horizontal'" 
+         [gap]="6">
+      @for (detail of getVisibleDetails(); track detail.label) {
+      <div [cdsStack]="'vertical'" 
+           [gap]="2">
+        <label class="cds--type-label-01">{{ detail.label }}</label>
+        <div class="cds--type-body-01">
+          @switch (detail.type) {
+            @case ('status') {
+              <div [cdsStack]="'horizontal'" 
+                   [gap]="3">
+                <cd-icon [type]="getStatusIcon(detail)"></cd-icon>
+                <span>{{ getDisplayValue(detail.value) }}</span>
+              </div>
+            }
+            
+            @default {
+              <span>{{ getDisplayValue(detail.value) }}</span>
+            }
+          }
+        </div>
+      </div>
+      }
     </div>
+    }
+    <ng-content></ng-content>
   </div>
-</cds-tile>
+</cd-productive-card>
index 5d896839cd4a441097e73ab9dc539fbb6d25798a..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 (file)
@@ -1,17 +0,0 @@
-@use '@carbon/layout';
-
-.details-card-header {
-  align-items: center;
-  display: flex;
-  justify-content: space-between;
-  padding-bottom: layout.$spacing-05;
-  position: relative;
-}
-
-::ng-deep .cds--label {
-  display: block;
-}
-
-.cds--row {
-  row-gap: layout.$spacing-05;
-}
index da32634778db75999d2fdc078014207e11676247..8e1912a5da04e83b696c018c9c1fe2a5e0c2f15b 100644 (file)
@@ -19,4 +19,32 @@ describe('DetailsCardComponent', () => {
   it('should create', () => {
     expect(component).toBeTruthy();
   });
+
+  it('should filter hidden details', () => {
+    component.details = [
+      { label: 'Visible', value: 'test', type: 'text' },
+      { label: 'Hidden', value: 'test', type: 'text', hidden: true }
+    ];
+    const visible = component.getVisibleDetails();
+    expect(visible.length).toBe(1);
+    expect(visible[0].label).toBe('Visible');
+  });
+
+  it('should detect disabled status', () => {
+    expect(component.isStatusDisabled('Disabled')).toBe(true);
+    expect(component.isStatusDisabled('disabled')).toBe(true);
+    expect(component.isStatusDisabled('Enabled')).toBe(false);
+  });
+
+  it('should handle empty details array', () => {
+    component.details = [];
+    const visible = component.getVisibleDetails();
+    expect(visible.length).toBe(0);
+  });
+
+  it('should handle undefined details', () => {
+    component.details = undefined;
+    const visible = component.getVisibleDetails();
+    expect(visible.length).toBe(0);
+  });
 });
index 9cfdb999939d98810a919d07f2521aacee6424ee..41d69190d5bb5e20e0549ad3c979d79ef0fab1d1 100644 (file)
@@ -1,12 +1,64 @@
-import { Component, Input } from '@angular/core';
+import { Component, Input, TemplateRef } from '@angular/core';
+
+type DetailValue = any;
+type DetailTemplateContext = { $implicit: DetailValue; detail: DetailItem };
+
+export interface DetailItem {
+  label: string;
+  value: DetailValue;
+  type?: 'text' | 'status' | 'custom';
+  statusIcon?: 'success' | 'error' | 'warning' | 'notification';
+  customTemplate?: TemplateRef<DetailTemplateContext>;
+  hidden?: boolean;
+}
 
 @Component({
   selector: 'cd-details-card',
   templateUrl: './details-card.component.html',
-  styleUrl: './details-card.component.scss',
+  styleUrls: ['./details-card.component.scss'],
   standalone: false
 })
 export class DetailsCardComponent {
   @Input()
-  cardTitle: string;
+  cardTitle?: string;
+
+  @Input()
+  details?: DetailItem[] = [];
+
+  @Input()
+  columns = 4;
+
+  getVisibleDetails(): DetailItem[] {
+    return (this.details || []).filter((detail) => !detail.hidden);
+  }
+
+  getDetailContext(detail: DetailItem): DetailTemplateContext {
+    return {
+      $implicit: detail.value,
+      detail
+    };
+  }
+
+  getStatusIcon(detail: DetailItem): 'success' | 'error' | 'warning' | 'notification' {
+    if (detail.statusIcon) {
+      return detail.statusIcon;
+    }
+    return this.isStatusDisabled(detail.value) ? 'error' : 'success';
+  }
+
+  hasValue(value: DetailValue): boolean {
+    return value !== null && value !== undefined && value !== '';
+  }
+
+  getDisplayValue(value: DetailValue): DetailValue | string {
+    return this.hasValue(value) ? value : '-';
+  }
+
+  isStatusDisabled(value: DetailValue): boolean {
+    if (value === null || value === undefined) {
+      return false;
+    }
+    const normalized = String(value).trim().toLowerCase();
+    return normalized === 'disabled';
+  }
 }
index f38d638a62b197a219eea64f2712305001b85923..a702d0fe041130848d05f5bc686f1ceb3f19ac5f 100644 (file)
@@ -70,3 +70,7 @@ Using `color` in css and seyting svg will fill="currentColor does not work.
 .arrowDown-icon {
   fill: theme.$support-error !important;
 }
+
+.edit-icon {
+  fill: var(--cds-link-primary) !important;
+}
index f9e9cae8e53cc651144eff4069fe3cd457411c23..1304bb4bc29cc559b773409e9d250ccf7f00c659 100644 (file)
@@ -2,7 +2,7 @@
           [ngClass]="{'productive-card--shadow': applyShadow}"
           [cdsLayer]="0">
   <div class="productive-card-header">
-    <ng-container *ngTemplateOutlet="headerTemplate"></ng-container>
+    <ng-container *ngTemplateOutlet="headerTemplate || null"></ng-container>
   </div>
   <section class="productive-card-section cds--type-body-compact-01"
            [ngClass]="{'productive-card-section--footer': footerTemplate}">
@@ -10,7 +10,7 @@
   </section>
   @if(!!footerTemplate) {
   <footer class="productive-card-footer">
-    <ng-container *ngTemplateOutlet="footerTemplate"></ng-container>
+    <ng-container *ngTemplateOutlet="footerTemplate || null"></ng-container>
   </footer>
   }
 </cds-tile>
index 3e212b318ec35bbe34b0b6a3a253fe878650d5f0..493b776f4b1e9200386b6d480114deb0aedeca3c 100644 (file)
@@ -7,7 +7,7 @@
   <div class="sidebar-layout-shell">
     <cds-sidenav
       [expanded]="true"
-      class="sidebar-layout-nav">
+      class="sidebar-layout-nav cds--side-nav--fixed">
       @for (item of items; track item.label) {
       <cds-sidenav-item
         [route]="item.route"
index b4570fdbe48ae3547fcd4488270b72c44f6a8ba7..5e7c58b68b2ddf2d3ffbc089bae1b8215ab61f01 100644 (file)
 
 .sidebar-layout-nav {
   background-color: var(--cds-layer-03);
+
+  .cds--side-nav__icon:not(.cds--side-nav__submenu-chevron) {
+    display: none;
+  }
 }
 
 .sidebar-layout-main {