From: pujashahu Date: Fri, 12 Jun 2026 22:05:13 +0000 (+0530) Subject: mgr/dashboard: Implement NVMe-oF Gateway Group Resource Overview Page X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=fb357cb4c49b515ad619f943d7615cf0fdbe5bf2;p=ceph.git mgr/dashboard: Implement NVMe-oF Gateway Group Resource Overview Page Fixes: https://tracker.ceph.com/issues/77383 Signed-off-by: pujaoshahu --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index 135ec9134b4..368a7de95b7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -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', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss index 9d8edd73999..e69de29bb2d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss @@ -1,8 +0,0 @@ -.breadcrumbs--padding { - padding-left: 0 !important; -} - -.cds--breadcrumb { - margin-top: 0; - padding: var(--cds-spacing-05); -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts index f5f13e38442..55b8056f942 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts @@ -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 } }, 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 index d3b43ababa9..c04056a900d 100644 --- 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 @@ -4,7 +4,7 @@ [fullWidth]="true">
-
- - + +
+ + +
@@ -37,7 +40,7 @@
+ [gap]="4"> @if (gateway.running > 0) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.scss index e69de29bb2d..a5f8a62aac9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.scss @@ -0,0 +1,6 @@ +@use '@carbon/layout'; + +cd-details-card { + display: block; + margin-bottom: layout.$spacing-05; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts index fe2100fc90b..dcaced4aaaa 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts @@ -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; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts index 5d622a0bdef..b59f235e8af 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts @@ -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(); @ViewChild(TableComponent, { static: true }) - table: TableComponent; + table!: TableComponent; @ViewChild('dateTpl', { static: true }) - dateTpl: TemplateRef; + dateTpl!: TemplateRef; @ViewChild('customTableItemTemplate', { static: true }) - customTableItemTemplate: TemplateRef; + customTableItemTemplate!: TemplateRef; @ViewChild('deleteTpl', { static: true }) - deleteTpl: TemplateRef; + deleteTpl!: TemplateRef; @ViewChild('gatewayStatusTpl', { static: true }) - gatewayStatusTpl: TemplateRef; + gatewayStatusTpl!: TemplateRef; - permission: Permission; - tableActions: CdTableAction[]; + permission!: Permission; + tableActions: CdTableAction[] = []; nodesAvailable = false; columns: CdTableColumn[] = []; selection: CdTableSelection = new CdTableSelection(); - gatewayGroup$: Observable; + gatewayGroup$: Observable = of([]); subject = new BehaviorSubject([]); - 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) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html index 497c5797454..ad98498ca6b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html @@ -1,90 +1,68 @@ - - -

+ + +

Add gateway nodes

-

+

Select NVMe-oF gateway nodes to associate with this gateway group.

-
-
-
- Select gateway nodes -
-
- Nodes to run NVMe-oF target service -
-
+
+

Select gateway nodes

+

Nodes to run NVMe-oF target pods/services

+
- - + + + - - {{ value || '-' }} - + + {{ value || '-' }} + - -
+ +
@if (value === HostStatus.AVAILABLE) { - + } {{ value | titlecase }} -
-
+
+
- + @if (value && value.length > 0) { - {{ label }} + {{ label }} } @else { - - + - } - + -
- + -
+ \ No newline at end of file diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss index dbb535acfd3..f93358bb00d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss @@ -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); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts index 6c447d2e244..ee8d64dfa3a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts @@ -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[] = []; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html index 678e0cd5f63..7e420bfe86e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html @@ -1,77 +1,70 @@ -
- - + @if (mode === 'details' && groupName) { + + + } + + @if (mode === 'details') { +
+

Gateway nodes

+

Nodes running NVMe-oF target pods/services

+
+ } + +
- + {{ value }} - + {{ value || '-' }} - -@if (value) { -
- @if (value === HostStatus.AVAILABLE || value === HostStatus.RUNNING) { + + @if (value) { +
+ @if (value === HostStatus.AVAILABLE || value === HostStatus.RUNNING) { - } @else { + } @else { - } - {{ value | titlecase }} + } + {{ value | titlecase }}
-} @else { + } @else { - -} + }
- -@if (value && value.length > 0) { - {{ label }} -} @else { + + @if (value && value.length > 0) { + {{ label }} + } @else { - -} - - - + } + \ No newline at end of file diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts index 2de89e3a478..b0595304e74 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts @@ -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 = 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' + } + ]; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html index b482607b371..bdac48c78b2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html @@ -62,34 +62,34 @@

- + @if (groupForm.controls.enableEncryption.value) {
Encryption configuration + i18n-helperText + [invalid]="encryptionConfigRef.isInvalid">Encryption configuration + This field is required.
- - This field is required. - }
Subsystem NQN (NVMe Qualified Name) @@ -36,6 +35,11 @@ formControlName="nqn" [invalid]="nqnRef.isInvalid"> + @if (nqnRef.isInvalid) { + @for (err of formGroup.get('nqn')?.errors | keyvalue; track err.key) { + {{ INVALID_TEXTS[err.key] }} + } + }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts index f848d67c66e..e62934ca8d4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts @@ -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(); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss index 7eae41be97c..e32f41ab605 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss @@ -31,7 +31,7 @@ padding-right: 0; .breadcrumbs--padding { - padding-left: var(--cds-spacing-07); + padding-left: var(--cds-spacing-05); } } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 5fbd0930b45..15452fcf264 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -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 { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.html index 9a402f60c59..dd302459d61 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.html @@ -1,14 +1,38 @@ - -@if (cardTitle) { -
- {{ cardTitle }} -
-} + + + @if (cardTitle) { +

{{ cardTitle }}

+ } +
-
-
- +
+ @if (details && details.length > 0) { +
+ @for (detail of getVisibleDetails(); track detail.label) { +
+ +
+ @switch (detail.type) { + @case ('status') { +
+ + {{ getDisplayValue(detail.value) }} +
+ } + + @default { + {{ getDisplayValue(detail.value) }} + } + } +
+
+ }
+ } +
- + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.scss index 5d896839cd4..e69de29bb2d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.scss @@ -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; -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.spec.ts index da32634778d..8e1912a5da0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.spec.ts @@ -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); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.ts index 9cfdb999939..41d69190d5b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/details-card/details-card.component.ts @@ -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; + 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'; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss index f38d638a62b..a702d0fe041 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss @@ -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; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html index f9e9cae8e53..1304bb4bc29 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html @@ -2,7 +2,7 @@ [ngClass]="{'productive-card--shadow': applyShadow}" [cdsLayer]="0">
- +
@@ -10,7 +10,7 @@
@if(!!footerTemplate) {
- +
} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html index 3e212b318ec..493b776f4b1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html @@ -7,7 +7,7 @@