From: Syed Ali Ul Hasan Date: Sun, 12 Jul 2026 13:39:00 +0000 (+0530) Subject: mgr/dashboard: revamped hosts overview resource page X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=9b1ba0712f9937fd5eb4b27469f4dceaaeea3942;p=ceph.git mgr/dashboard: revamped hosts overview resource page - Added a new shared overview component for the resource page - Added a new host-action service - Fixes: https://tracker.ceph.com/issues/76712 Signed-off-by: Syed Ali Ul Hasan --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 95f1449aa9b..3f0df6b4256 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -8,10 +8,10 @@ import { ConfigurationFormComponent } from './ceph/cluster/configuration/configu import { ConfigurationComponent } from './ceph/cluster/configuration/configuration.component'; import { CreateClusterComponent } from './ceph/cluster/create-cluster/create-cluster.component'; import { CrushmapComponent } from './ceph/cluster/crushmap/crushmap.component'; -import { HostDetailsComponent } from './ceph/cluster/hosts/host-details/host-details.component'; +import { HostSidebarComponent } from './ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component'; import { HostFormComponent } from './ceph/cluster/hosts/host-form/host-form.component'; -import { HostDetailsBreadcrumbResolver } from './ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver'; -import { HostDetailsSectionComponent } from './ceph/cluster/hosts/host-details/host-details-section.component'; +import { HostResourceBreadcrumbResolver } from './ceph/cluster/hosts/host-resource-page/host-resource-breadcrumb.resolver'; +import { HostResourcePageComponent } from './ceph/cluster/hosts/host-resource-page/host-resource-page.component'; import { HostsComponent } from './ceph/cluster/hosts/hosts.component'; import { InventoryComponent } from './ceph/cluster/inventory/inventory.component'; import { LogsComponent } from './ceph/cluster/logs/logs.component'; @@ -158,34 +158,29 @@ const routes: Routes = [ }, { path: 'hosts/:hostname', - component: HostDetailsComponent, - data: { breadcrumbs: HostDetailsBreadcrumbResolver }, + component: HostSidebarComponent, + data: { breadcrumbs: HostResourceBreadcrumbResolver }, children: [ - { path: '', redirectTo: 'devices', pathMatch: 'full' }, + { path: '', redirectTo: 'overview', pathMatch: 'full' }, { - path: 'devices', - component: HostDetailsSectionComponent, - data: { breadcrumbs: 'Devices', section: 'devices' } + path: 'overview', + component: HostResourcePageComponent, + data: { breadcrumbs: 'Overview', section: 'overview' } }, { - path: 'physical-disks', - component: HostDetailsSectionComponent, - data: { breadcrumbs: 'Physical Disks', section: 'physical-disks' } + path: 'storage-devices', + component: HostResourcePageComponent, + data: { breadcrumbs: 'Storage Devices', section: 'storage-devices' } }, { path: 'daemons', - component: HostDetailsSectionComponent, + component: HostResourcePageComponent, data: { breadcrumbs: 'Daemons', section: 'daemons' } }, { - path: 'performance-details', - component: HostDetailsSectionComponent, - data: { breadcrumbs: 'Performance Details', section: 'performance-details' } - }, - { - path: 'device-health', - component: HostDetailsSectionComponent, - data: { breadcrumbs: 'Device health', section: 'device-health' } + path: 'performance', + component: HostResourcePageComponent, + data: { breadcrumbs: 'Performance', section: 'performance' } } ] }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index ac33390869a..6be23e612fa 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -28,7 +28,9 @@ import { RadioModule, TilesModule, LayerModule, - AccordionModule + AccordionModule, + MenuButtonModule, + ContextMenuModule } from 'carbon-components-angular'; import Analytics from '@carbon/icons/es/analytics/16'; import CloseFilled from '@carbon/icons/es/close--filled/16'; @@ -60,8 +62,8 @@ import { CreateClusterStep3Component } from './create-cluster/create-cluster-ste import { CreateClusterStep4Component } from './create-cluster/create-cluster-step-4/create-cluster-step-4.component'; import { CreateClusterComponent } from './create-cluster/create-cluster.component'; import { CrushmapComponent } from './crushmap/crushmap.component'; -import { HostDetailsComponent } from './hosts/host-details/host-details.component'; -import { HostDetailsSectionComponent } from './hosts/host-details/host-details-section.component'; +import { HostSidebarComponent } from './hosts/host-resource-sidebar/host-resource-sidebar.component'; +import { HostResourcePageComponent } from './hosts/host-resource-page/host-resource-page.component'; import { HostFormComponent } from './hosts/host-form/host-form.component'; import { HostsComponent } from './hosts/hosts.component'; import { InventoryDevicesComponent } from './inventory/inventory-devices/inventory-devices.component'; @@ -148,7 +150,9 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/ RadioModule, TilesModule, LayerModule, - AccordionModule + AccordionModule, + MenuButtonModule, + ContextMenuModule ], declarations: [ MonitorComponent, @@ -157,8 +161,8 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/ OsdDetailsComponent, OsdScrubModalComponent, OsdFlagsModalComponent, - HostDetailsComponent, - HostDetailsSectionComponent, + HostSidebarComponent, + HostResourcePageComponent, ConfigurationDetailsComponent, ConfigurationFormComponent, OsdReweightModalComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts deleted file mode 100644 index fe277471be4..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-breadcrumb.resolver.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot } from '@angular/router'; - -import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs'; - -@Injectable({ - providedIn: 'root' -}) -export class HostDetailsBreadcrumbResolver extends BreadcrumbsResolver { - resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] { - const hostname = route.parent?.params?.hostname || route.params?.hostname || ''; - return [ - { text: 'Cluster/Hosts', path: '/hosts' }, - { text: hostname, path: this.getFullPath(route) } - ]; - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html deleted file mode 100644 index a9a70e31b40..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.html +++ /dev/null @@ -1,38 +0,0 @@ -@if (hostname) { - @switch (section) { - @case ('devices') { - - } - @case ('physical-disks') { - - } - @case ('daemons') { - - - } - @case ('performance-details') { - - - } - @case ('device-health') { - - } - } -} @else { - No hostname found. -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts deleted file mode 100644 index c4e92ddc853..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details-section.component.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute, ParamMap } from '@angular/router'; - -@Component({ - selector: 'cd-host-details-section', - templateUrl: './host-details-section.component.html', - standalone: false -}) -export class HostDetailsSectionComponent implements OnInit { - hostname = ''; - section = ''; - - constructor(private route: ActivatedRoute) {} - - ngOnInit(): void { - this.route.parent?.paramMap.subscribe((pm: ParamMap) => { - this.hostname = pm.get('hostname') ?? ''; - }); - this.section = this.route.snapshot.data['section'] ?? ''; - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html deleted file mode 100644 index 35343d1f505..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.scss deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts deleted file mode 100644 index f0086af4365..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap } from '@angular/router'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { of } from 'rxjs'; - -import { CephModule } from '~/app/ceph/ceph.module'; -import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module'; -import { CoreModule } from '~/app/core/core.module'; -import { Permissions } from '~/app/shared/models/permissions'; -import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; -import { SharedModule } from '~/app/shared/shared.module'; -import { configureTestBed } from '~/testing/unit-test-helper'; -import { HostDetailsComponent } from './host-details.component'; - -describe('HostDetailsComponent', () => { - let component: HostDetailsComponent; - let fixture: ComponentFixture; - - configureTestBed({ - imports: [ - BrowserAnimationsModule, - HttpClientTestingModule, - RouterTestingModule, - CephModule, - CoreModule, - CephSharedModule, - SharedModule - ], - providers: [ - { - provide: ActivatedRoute, - useValue: { - paramMap: of(convertToParamMap({ hostname: 'localhost' })) - } - }, - { - provide: AuthStorageService, - useValue: { - getPermissions: () => new Permissions({ hosts: ['read'], grafana: ['read'] }) - } - } - ] - }); - - beforeEach(() => { - fixture = TestBed.createComponent(HostDetailsComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('Host resource layout', () => { - beforeEach(() => { - fixture.detectChanges(); - }); - - it('should render the sidebar layout', () => { - const layout = fixture.nativeElement.querySelector('cd-sidebar-layout'); - expect(layout).toBeTruthy(); - }); - - it('should build the sidebar items', () => { - expect(component.sidebarItems.map((item) => item.label)).toEqual([ - 'Devices', - 'Physical Disks', - 'Daemons', - 'Performance Details', - 'Device health' - ]); - }); - - it('should set the hostname title', () => { - expect(component.hostname).toBe('localhost'); - }); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts deleted file mode 100644 index 28a7602e133..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; -import { ActivatedRoute, ParamMap } from '@angular/router'; - -import { Subscription } from 'rxjs'; - -import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; -import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component'; - -@Component({ - selector: 'cd-host-details', - templateUrl: './host-details.component.html', - styleUrls: ['./host-details.component.scss'], - encapsulation: ViewEncapsulation.None, - standalone: false -}) -export class HostDetailsComponent implements OnInit, OnDestroy { - private sub = new Subscription(); - public readonly basePath = '/hosts'; - hostname = ''; - sidebarItems: SidebarItem[] = []; - - constructor( - private route: ActivatedRoute, - private authStorageService: AuthStorageService - ) {} - - ngOnInit(): void { - const permissions = this.authStorageService.getPermissions(); - this.sub.add( - this.route.paramMap.subscribe((pm: ParamMap) => { - this.hostname = pm.get('hostname') ?? ''; - this.buildSidebarItems(permissions); - }) - ); - } - - ngOnDestroy(): void { - this.sub.unsubscribe(); - } - - private buildSidebarItems(permissions: any): void { - const items: SidebarItem[] = [ - { - label: $localize`Devices`, - route: [this.basePath, this.hostname, 'devices'], - routerLinkActiveOptions: { exact: true } - } - ]; - - if (permissions.hosts?.read) { - items.push( - { - label: $localize`Physical Disks`, - route: [this.basePath, this.hostname, 'physical-disks'], - routerLinkActiveOptions: { exact: true } - }, - { - label: $localize`Daemons`, - route: [this.basePath, this.hostname, 'daemons'], - routerLinkActiveOptions: { exact: true } - } - ); - } - - if (permissions.grafana?.read) { - items.push({ - label: $localize`Performance Details`, - route: [this.basePath, this.hostname, 'performance-details'], - routerLinkActiveOptions: { exact: true } - }); - } - - items.push({ - label: $localize`Device health`, - route: [this.basePath, this.hostname, 'device-health'], - routerLinkActiveOptions: { exact: true } - }); - - this.sidebarItems = items; - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-breadcrumb.resolver.ts new file mode 100644 index 00000000000..e2a9c1b68ac --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-breadcrumb.resolver.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs'; + +@Injectable({ + providedIn: 'root' +}) +export class HostResourceBreadcrumbResolver extends BreadcrumbsResolver { + resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] { + const hostname = route.parent?.params?.hostname || route.params?.hostname || ''; + return [ + { text: 'Cluster/Hosts', path: '/hosts' }, + { text: hostname, path: this.getFullPath(route) } + ]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.html new file mode 100644 index 00000000000..55bdb202dad --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.html @@ -0,0 +1,92 @@ +@if (hostname) { + @switch (section) { + @case ('overview') { + + + } + @case ('storage-devices') { +
+

+ Ceph Devices +

+

+ Storage devices managed by Ceph, including OSD associations, allocation details, and + device roles used for cluster storage operations. +

+ +
+ + @if (permissions.hosts.read) { +
+

+ Physical Disks +

+

+ View the underlying physical drives attached to the host, including hardware details, + capacity, media type, and disk inventory information. +

+ + +
+ } + +
+

+ Health & diagnostics +

+ +
+ } + @case ('daemons') { + @if (permissions.hosts.read) { + + + } + } + @case ('performance') { + @if (permissions.grafana.read) { + + + } + } + } +} @else { + No hostname found. +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.spec.ts new file mode 100644 index 00000000000..6747d22b4eb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.spec.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { HttpParams } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, convertToParamMap, ParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BehaviorSubject, of, throwError } from 'rxjs'; + +import { HostService } from '~/app/shared/api/host.service'; +import { ICON_TYPE } from '~/app/shared/enum/icons.enum'; +import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { FormatterService } from '~/app/shared/services/formatter.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { HostResourcePageComponent } from './host-resource-page.component'; + +describe('HostResourcePageComponent', () => { + let component: HostResourcePageComponent; + let fixture: ComponentFixture; + + const hostServiceSpy = { + list: jest.fn(), + getTotalMemoryBytes: jest.fn((host?: { memory_total_kb?: number | string }) => { + const memoryKb = Number(host?.memory_total_kb); + return Number.isFinite(memoryKb) ? memoryKb * 1024 : undefined; + }), + getRawCapacityBytes: jest.fn( + (host?: { hdd_capacity_bytes?: number | string; flash_capacity_bytes?: number | string }) => { + const hdd = Number(host?.hdd_capacity_bytes); + const flash = Number(host?.flash_capacity_bytes); + return Number.isFinite(hdd) && Number.isFinite(flash) ? hdd + flash : undefined; + } + ) + }; + const parentParamMap$ = new BehaviorSubject(convertToParamMap({ hostname: 'node-1' })); + + configureTestBed({ + declarations: [HostResourcePageComponent], + imports: [HttpClientTestingModule, RouterTestingModule, SharedModule], + providers: [ + FormatterService, + { + provide: ActivatedRoute, + useValue: { + parent: { paramMap: parentParamMap$.asObservable() }, + snapshot: { data: { section: 'overview' } } + } + }, + { + provide: HostService, + useValue: hostServiceSpy + }, + { + provide: AuthStorageService, + useValue: { + getPermissions: () => new Permissions({ hosts: ['read'], grafana: ['read'] }) + } + } + ] + }); + + beforeEach(() => { + /* Create a fresh component instance for each test */ + fixture = TestBed.createComponent(HostResourcePageComponent); + component = fixture.componentInstance; + + /* Clear mock call history between tests */ + hostServiceSpy.list.mockClear(); + hostServiceSpy.getTotalMemoryBytes.mockClear(); + hostServiceSpy.getRawCapacityBytes.mockClear(); + }); + + const getField = (label: string) => { + return component.hostOverviewFields?.find((field) => field.label === label); + }; + + it('should create', () => { + hostServiceSpy.list.mockReturnValue(of([])); + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('Overview layout', () => { + it('should build overview fields from host inventory details', () => { + hostServiceSpy.list.mockReturnValue( + of([ + { + hostname: 'node-1', + addr: '10.0.0.1', + labels: ['_admin', 'storage'], + status: 'maintenance', + model: 'PowerEdge R750', + cpu_count: '2', + cpu_cores: 32, + memory_total_kb: 1024, + hdd_capacity_bytes: 1024, + flash_capacity_bytes: 2048, + hdd_count: '4', + flash_count: 2, + nic_count: '6' + } + ]) + ); + + fixture.detectChanges(); + + expect(hostServiceSpy.list).toHaveBeenCalledWith(expect.any(HttpParams), 'true'); + expect(component.hostname).toBe('node-1'); + expect(component.section).toBe('overview'); + + expect(getField('Hostname')).toEqual(expect.objectContaining({ value: 'node-1 (10.0.0.1)' })); + expect(getField('Labels')).toEqual( + expect.objectContaining({ values: ['_admin', 'storage'] }) + ); + expect(getField('Status')).toEqual( + expect.objectContaining({ value: 'Maintenance', status: ICON_TYPE.warning }) + ); + expect(getField('Model')).toEqual(expect.objectContaining({ value: 'PowerEdge R750' })); + expect(getField('CPUs')).toEqual(expect.objectContaining({ value: '2' })); + expect(getField('Cores')).toEqual(expect.objectContaining({ value: 32 })); + expect(getField('Total Memory')?.value).toBe('1 MiB'); // 1024 KB transformed + expect(getField('Raw Capacity')?.value).toBe('3 KiB'); // 1024 + 2048 Bytes transformed + expect(getField('HDDs')).toEqual(expect.objectContaining({ value: '4' })); + expect(getField('Flash')).toEqual(expect.objectContaining({ value: 2 })); + expect(getField('NICs')).toEqual(expect.objectContaining({ value: '6' })); + }); + + it('should fall back to placeholder overview values when host loading fails', () => { + hostServiceSpy.list.mockReturnValue(throwError(() => new Error('API Error'))); + + fixture.detectChanges(); + + expect(getField('Hostname')).toEqual(expect.objectContaining({ value: 'node-1' })); + expect(getField('Labels')).toEqual(expect.objectContaining({ values: [] })); + expect(getField('Status')).toEqual(expect.objectContaining({ value: undefined })); + expect(getField('Model')).toEqual(expect.objectContaining({ value: undefined })); + expect(getField('Total Memory')).toEqual(expect.objectContaining({ value: '-' })); + expect(getField('Raw Capacity')).toEqual(expect.objectContaining({ value: '-' })); + }); + + it('should keep capacity values empty if standard fact fields are missing', () => { + hostServiceSpy.list.mockReturnValue( + of([ + { + hostname: 'node-1', + memory_total_bytes: 2048, + raw_capacity: 4096 + } + ]) + ); + + fixture.detectChanges(); + + expect(getField('Total Memory')?.value).toEqual('-'); + expect(getField('Raw Capacity')?.value).toEqual('-'); + }); + }); + + describe('Permission-gated template sections', () => { + it('should show Physical Disks inventory in storage-devices section when hosts.read is allowed', () => { + hostServiceSpy.list.mockReturnValue(of([])); + fixture.detectChanges(); + + component.section = 'storage-devices'; + component.permissions = new Permissions({ hosts: ['read'], grafana: ['read'] }); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('cd-inventory'))).toBeTruthy(); + }); + + it('should hide Physical Disks inventory in storage-devices section when hosts.read is denied', () => { + hostServiceSpy.list.mockReturnValue(of([])); + fixture.detectChanges(); + + component.section = 'storage-devices'; + component.permissions = new Permissions({ hosts: [], grafana: ['read'] }); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('cd-inventory'))).toBeFalsy(); + }); + + it('should show daemon list in daemons section when hosts.read is allowed', () => { + hostServiceSpy.list.mockReturnValue(of([])); + fixture.detectChanges(); + + component.section = 'daemons'; + component.permissions = new Permissions({ hosts: ['read'], grafana: ['read'] }); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('cd-service-daemon-list'))).toBeTruthy(); + }); + + it('should hide daemon list in daemons section when hosts.read is denied', () => { + hostServiceSpy.list.mockReturnValue(of([])); + fixture.detectChanges(); + + component.section = 'daemons'; + component.permissions = new Permissions({ hosts: [], grafana: ['read'] }); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('cd-service-daemon-list'))).toBeFalsy(); + }); + + it('should show grafana panel in performance section when grafana.read is allowed', () => { + hostServiceSpy.list.mockReturnValue(of([])); + fixture.detectChanges(); + + component.section = 'performance'; + component.permissions = new Permissions({ hosts: ['read'], grafana: ['read'] }); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('cd-grafana'))).toBeTruthy(); + }); + + it('should hide grafana panel in performance section when grafana.read is denied', () => { + hostServiceSpy.list.mockReturnValue(of([])); + fixture.detectChanges(); + + component.section = 'performance'; + component.permissions = new Permissions({ hosts: ['read'], grafana: [] }); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('cd-grafana'))).toBeFalsy(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.ts new file mode 100644 index 00000000000..626a79495f7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-page/host-resource-page.component.ts @@ -0,0 +1,145 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { HttpParams } from '@angular/common/http'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { HostService } from '~/app/shared/api/host.service'; +import { OverviewField } from '~/app/shared/components/resource-overview-card/resource-overview-card.component'; +import { HostOverviewDetails, STATUS_MAP, getStatus } from '~/app/shared/models/host.interface'; +import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { FormatterService } from '~/app/shared/services/formatter.service'; + +@Component({ + selector: 'cd-host-resource-page', + templateUrl: './host-resource-page.component.html', + standalone: false +}) +export class HostResourcePageComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + hostname = ''; + section = ''; + permissions: Permissions; + hostOverviewFields: OverviewField[] = []; + + constructor( + private route: ActivatedRoute, + private hostService: HostService, + private authStorageService: AuthStorageService, + private formatter: FormatterService + ) { + this.permissions = this.authStorageService.getPermissions(); + } + + ngOnInit(): void { + this.sub.add( + this.route.parent?.paramMap.subscribe((pm: ParamMap) => { + this.hostname = pm.get('hostname') ?? ''; + this.loadOverview(); + }) + ); + this.section = this.route.snapshot.data['section'] ?? ''; + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private loadOverview(): void { + if (!this.hostname) { + this.hostOverviewFields = []; + return; + } + + let params = new HttpParams(); + params = params.set('offset', '0'); + params = params.set('limit', '1'); + params = params.set('search', this.hostname); + params = params.set('sort', '+hostname'); + + this.sub.add( + this.hostService.list(params, 'true').subscribe({ + next: (hosts: object[]) => { + const hostList = (Array.isArray(hosts) ? hosts : []) as HostOverviewDetails[]; + const host = hostList.find((item) => item.hostname === this.hostname); + this.hostOverviewFields = this.buildOverviewFields(host); + }, + error: () => { + this.hostOverviewFields = this.buildOverviewFields(); + } + }) + ); + } + + private buildOverviewFields(host: Partial = {}): OverviewField[] { + const hostStatus = STATUS_MAP[getStatus(host)]; + const hostnameWithAddr = host?.addr ? `${this.hostname} (${host.addr})` : this.hostname; + const totalMemory = this.formatter.formatToBinary( + this.hostService.getTotalMemoryBytes(host), + false, + 1 + ); + const rawCapacity = this.formatter.formatToBinary( + this.hostService.getRawCapacityBytes(host), + false, + 1 + ); + + return [ + { + label: $localize`Hostname`, + value: hostnameWithAddr, + type: 'text' + }, + { + label: $localize`Labels`, + values: host?.labels ?? [], + type: 'tags' + }, + { + label: $localize`Status`, + value: hostStatus?.label, + type: 'status', + status: hostStatus?.icon + }, + { + label: $localize`Model`, + value: host?.model, + type: 'text' + }, + { + label: $localize`CPUs`, + value: host?.cpu_count, + type: 'text' + }, + { + label: $localize`Cores`, + value: host?.cpu_cores, + type: 'text' + }, + { + label: $localize`Total Memory`, + value: totalMemory + }, + { + label: $localize`Raw Capacity`, + value: rawCapacity + }, + { + label: $localize`HDDs`, + value: host?.hdd_count, + type: 'text' + }, + { + label: $localize`Flash`, + value: host?.flash_count, + type: 'text' + }, + { + label: $localize`NICs`, + value: host?.nic_count, + type: 'text' + } + ]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.html new file mode 100644 index 00000000000..fe3cc5f5d4e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.html @@ -0,0 +1,66 @@ + + @if (hostStatus || hostLabels.length || hostActions.length) { +
+
+ @if (hostStatus) { + + + {{ hostStatusMap?.label ?? hostStatus }} + + } + @for (label of hostLabels; track label) { + {{ label }} + } +
+ @if (hostActions.length) { +
+ + @for (action of getVisibleHostActions(); track action.name) { + + + } + +
+ } +
+ } +
+ + + @for (msg of errorMessage; track $index; let last = $last) { +
+ @if (!last || errorMessage.length === 1) { +
    +
  • {{ msg }}
  • +
+ } +
+ } + Are you sure you want to continue? +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.scss new file mode 100644 index 00000000000..46a4f0c6ff6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.scss @@ -0,0 +1,87 @@ +@use '@carbon/layout'; +@use '@carbon/colors'; + +.host-header-extra { + display: flex; + align-items: center; + justify-content: space-between; + gap: layout.$spacing-05; + flex: 1 1 auto; + min-width: 0; +} + +.host-header-tags { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: layout.$spacing-02; + min-width: 0; +} + +.host-header-actions { + margin-left: auto; +} + +.host-details-layout .sidebar-header-content { + flex-wrap: nowrap; + width: 100%; +} + +.host-details-layout .sidebar-header-content h2 { + white-space: nowrap; + flex-shrink: 0; +} + +.host-details-layout .sidebar-header { + padding-right: var(--cds-spacing-07); +} + +.host-status-badge { + display: inline-flex; + align-items: center; + gap: var(--cds-spacing-02); + font-size: var(--cds-body-compact-01-font-size, 0.875rem); + font-weight: 600; +} + +.host-status-badge--success { + color: var(--cds-support-success); +} + +.host-status-badge--warning { + color: var(--cds-support-caution-major); +} + +.host-status-badge--danger { + color: var(--cds-support-error); +} + +.host-status-badge--info { + color: var(--cds-support-info); +} + +/* Menu is rendered under document.body; scope fixes by menu id. */ +#host-actions-menu.cds--menu { + width: max-content !important; + min-width: 12rem; + background-color: colors.$white !important; +} + +#host-actions-menu.cds--menu > .cds--menu-item { + grid-template-columns: minmax(0, 1fr) !important; +} + +#host-actions-menu.cds--menu .cds--menu-item { + color: var(--cds-text-primary); +} + +#host-actions-menu.cds--menu .cds--menu-item__label { + overflow: visible; + text-overflow: unset; + white-space: normal; +} + +#host-actions-menu.cds--menu .cds--menu-item:hover, +#host-actions-menu.cds--menu .cds--menu-item:focus { + background-color: colors.$gray-10; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.spec.ts new file mode 100644 index 00000000000..20dc3f4eb16 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.spec.ts @@ -0,0 +1,94 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { CephModule } from '~/app/ceph/ceph.module'; +import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module'; +import { CoreModule } from '~/app/core/core.module'; +import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { HostService } from '~/app/shared/api/host.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { HostSidebarComponent } from './host-resource-sidebar.component'; + +describe('HostSidebarComponent', () => { + let component: HostSidebarComponent; + let fixture: ComponentFixture; + + configureTestBed({ + imports: [ + BrowserAnimationsModule, + HttpClientTestingModule, + RouterTestingModule, + CephModule, + CoreModule, + CephSharedModule, + SharedModule + ], + providers: [ + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ hostname: 'localhost' })) + } + }, + { + provide: AuthStorageService, + useValue: { + getPermissions: () => new Permissions({ hosts: ['read'], grafana: ['read'] }) + } + }, + { + provide: HostService, + useValue: { + getDisable: () => false, + getAllHosts: () => + of([ + { + hostname: 'localhost', + labels: ['_admin'], + status: 'available' + } + ]) + } + } + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HostSidebarComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Host resource layout', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should render the sidebar layout', () => { + const layout = fixture.nativeElement.querySelector('cd-sidebar-layout'); + expect(layout).toBeTruthy(); + }); + + it('should build the sidebar items', () => { + expect(component.sidebarItems.map((item) => item.label)).toEqual([ + 'Overview', + 'Storage Devices', + 'Daemons', + 'Performance' + ]); + }); + + it('should set the hostname title', () => { + expect(component.hostname).toBe('localhost'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.ts new file mode 100644 index 00000000000..7267771200b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-resource-sidebar/host-resource-sidebar.component.ts @@ -0,0 +1,274 @@ +import { + Component, + OnDestroy, + OnInit, + TemplateRef, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; + +import { Subscription } from 'rxjs'; + +import { OrchestratorService } from '~/app/shared/api/orchestrator.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { HostService, HostModalRef } from '~/app/shared/api/host.service'; +import { HostActionService } from '~/app/shared/services/host-action.service'; +import { Host, HostStatusConfig, STATUS_MAP, getStatus } from '~/app/shared/models/host.interface'; +import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component'; +import { ActionLabels, ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { HostStatus } from '~/app/shared/enum/host-status.enum'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum'; +import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface'; +import { Permissions } from '~/app/shared/models/permissions'; +@Component({ + selector: 'cd-host-sidebar', + templateUrl: './host-resource-sidebar.component.html', + styleUrls: ['./host-resource-sidebar.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class HostSidebarComponent implements OnInit, OnDestroy { + readonly HostStatus = HostStatus; + + @ViewChild('maintenanceConfirmTpl', { static: true }) + maintenanceConfirmTpl!: TemplateRef; + + private sub = new Subscription(); + public readonly basePath = '/hosts'; + hostname = ''; + hostLabels: string[] = []; + hostStatus: HostStatus | string | null = null; + hostStatusMap: HostStatusConfig | null = null; + hostActions: CdTableAction[] = []; + hostSelection = new CdTableSelection(); + sidebarItems: SidebarItem[] = []; + permissions: Permissions; + modalRef?: HostModalRef; + isExecuting = false; + errorMessage: string[] = []; + enableMaintenanceBtn = false; + draining = false; + orchStatus!: OrchestratorStatus; + + messages = { + nonOrchHost: $localize`The feature is disabled because the selected host is not managed by Orchestrator.` + }; + + actionOrchFeatures: Record = { + [ActionLabels.EDIT]: [ + OrchestratorFeature.HOST_LABEL_ADD, + OrchestratorFeature.HOST_LABEL_REMOVE + ], + [ActionLabels.REMOVE]: [OrchestratorFeature.HOST_REMOVE], + [ActionLabels.MAINTENANCE]: [ + OrchestratorFeature.HOST_MAINTENANCE_ENTER, + OrchestratorFeature.HOST_MAINTENANCE_EXIT + ], + [ActionLabels.DRAIN]: [OrchestratorFeature.HOST_DRAIN] + }; + + constructor( + private route: ActivatedRoute, + private authStorageService: AuthStorageService, + private hostService: HostService, + private hostActionService: HostActionService, + private actionLabels: ActionLabelsI18n, + private orchService: OrchestratorService + ) { + this.permissions = this.authStorageService.getPermissions(); + } + + ngOnInit(): void { + this.sub.add( + this.route.paramMap.subscribe((pm: ParamMap) => { + this.hostname = pm.get('hostname') ?? ''; + this.buildSidebarItems(this.permissions); + this.loadHostMetadata(); + }) + ); + + this.sub.add( + this.orchService.status().subscribe((orchStatus) => { + this.orchStatus = orchStatus; + this.buildHostActions(); + }) + ); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private buildSidebarItems(permissions: Permissions): void { + const items: SidebarItem[] = [ + { + label: $localize`Overview`, + route: [this.basePath, this.hostname, 'overview'], + routerLinkActiveOptions: { exact: true } + }, + { + label: $localize`Storage Devices`, + route: [this.basePath, this.hostname, 'storage-devices'], + routerLinkActiveOptions: { exact: true } + } + ]; + + if (permissions.hosts?.read) { + items.push({ + label: $localize`Daemons`, + route: [this.basePath, this.hostname, 'daemons'], + routerLinkActiveOptions: { exact: true } + }); + } + + if (permissions.grafana?.read) { + items.push({ + label: $localize`Performance`, + route: [this.basePath, this.hostname, 'performance'], + routerLinkActiveOptions: { exact: true } + }); + } + + this.sidebarItems = items; + } + + private loadHostMetadata(): void { + if (!this.hostname) { + this.hostLabels = []; + this.hostStatus = null; + this.hostStatusMap = null; + return; + } + + this.sub.add( + this.hostService.getAllHosts().subscribe((hosts: Host[]) => { + const host = hosts.find((item) => item.hostname === this.hostname); + this.hostLabels = Array.isArray(host?.labels) ? host.labels : []; + this.hostStatus = getStatus(host); + this.enableMaintenanceBtn = this.hostStatus === HostStatus.MAINTENANCE; + this.hostStatusMap = STATUS_MAP[this.hostStatus]; + this.draining = this.hostLabels.includes('_no_schedule'); + this.hostSelection.selected = host ? [host] : []; + this.buildHostActions(); + }) + ); + } + + private buildHostActions(): void { + this.hostActions = [ + { + name: this.actionLabels.EDIT, + permission: 'update', + icon: Icons.edit, + click: () => this.editAction(), + disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.EDIT, selection) + }, + { + name: this.draining ? this.actionLabels.STOP_DRAIN : this.actionLabels.START_DRAIN, + permission: 'update', + icon: Icons.exit, + click: () => this.hostDrain(this.draining), + disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.DRAIN, selection) + }, + { + name: this.actionLabels.REMOVE, + permission: 'delete', + icon: Icons.destroy, + click: () => this.deleteAction(), + disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.REMOVE, selection) + }, + { + name: this.enableMaintenanceBtn + ? this.actionLabels.EXIT_MAINTENANCE + : this.actionLabels.ENTER_MAINTENANCE, + permission: 'update', + icon: this.enableMaintenanceBtn ? Icons.exit : Icons.enter, + click: () => this.hostMaintenance(), + disable: (selection: CdTableSelection) => + this.getDisable(ActionLabels.MAINTENANCE, selection) || this.isExecuting + } + ]; + } + + getVisibleHostActions(): CdTableAction[] { + return this.hostActions.filter( + (action) => !action.visible || action.visible(this.hostSelection) + ); + } + + isHostActionDisabled(action: CdTableAction): boolean { + return !!action.disable?.(this.hostSelection); + } + + runHostAction(action: CdTableAction): void { + if (this.isHostActionDisabled(action)) { + return; + } + action.click?.(); + } + + getDisable(action: string, selection: CdTableSelection): boolean | string { + return this.hostService.getDisable( + action, + selection, + this.orchStatus as OrchestratorStatus, + this.actionOrchFeatures, + this.messages.nonOrchHost, + [ActionLabels.EDIT, ActionLabels.REMOVE, ActionLabels.MAINTENANCE, ActionLabels.DRAIN] + ); + } + + editAction() { + const host = this.hostSelection.first(); + if (!host) { + return; + } + + this.hostActionService.openEditModal(host, () => { + this.loadHostMetadata(); + }); + } + + hostMaintenance() { + const host = this.hostSelection.first(); + if (!host) { + return; + } + + this.hostActionService.hostMaintenance( + host, + this.maintenanceConfirmTpl, + (isExecuting: boolean) => (this.isExecuting = isExecuting), + (errorMessage: string[]) => (this.errorMessage = errorMessage), + () => this.loadHostMetadata(), + () => this.loadHostMetadata(), + (modalRef: HostModalRef) => { + this.modalRef = modalRef; + } + ); + } + + hostDrain(stop = false) { + const host = this.hostSelection.first(); + if (!host) { + return; + } + + this.hostActionService.hostDrain(host, stop, () => { + this.loadHostMetadata(); + }); + } + + deleteAction() { + const host = this.hostSelection.first(); + if (!host) { + return; + } + + this.modalRef = this.hostActionService.deleteAction(host.hostname); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts index c1ce3f7efab..21375beb0ba 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts @@ -239,8 +239,8 @@ describe('HostsComponent', () => { fixture.detectChanges(); component.getHosts(new CdTableFetchDataContext(() => undefined)); - expect(component.hosts[0]['memory_total_bytes']).toEqual('N/A'); - expect(component.hosts[0]['raw_capacity']).toEqual('N/A'); + expect(component.hosts[0]['memory_total_bytes']).toEqual('-'); + expect(component.hosts[0]['raw_capacity']).toEqual('-'); }); it('should show force maintenance modal when it is safe to stop host', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts index 07d29d63536..26ff22f284f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts @@ -1,40 +1,32 @@ import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { Router } from '@angular/router'; -import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import _ from 'lodash'; import { Subscription } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; -import { HostService } from '~/app/shared/api/host.service'; +import { HostService, HostModalRef, HostFactsCapacitySource } from '~/app/shared/api/host.service'; +import { HostActionService } from '~/app/shared/services/host-action.service'; import { OrchestratorService } from '~/app/shared/api/orchestrator.service'; +import { Host } from '~/app/shared/models/host.interface'; import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; -import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component'; -import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; -import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component'; -import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; -import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { ActionLabels, ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { TableComponent } from '~/app/shared/datatable/table/table.component'; import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; import { Icons } from '~/app/shared/enum/icons.enum'; -import { NotificationType } from '~/app/shared/enum/notification-type.enum'; import { CdTableAction } from '~/app/shared/models/cd-table-action'; import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; -import { FinishedTask } from '~/app/shared/models/finished-task'; import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum'; import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface'; import { Permissions } from '~/app/shared/models/permissions'; import { EmptyPipe } from '~/app/shared/pipes/empty.pipe'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { CdTableServerSideService } from '~/app/shared/services/cd-table-server-side.service'; -import { NotificationService } from '~/app/shared/services/notification.service'; -import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { HostFormComponent } from './host-form/host-form.component'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; -import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; const BASE_URL = 'hosts'; @@ -94,12 +86,12 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit tableActions: CdTableAction[]; expandClusterActions: CdTableAction[]; selection = new CdTableSelection(); - modalRef: NgbModalRef; + modalRef?: HostModalRef; isExecuting = false; - errorMessage: string; + errorMessage: string[]; enableMaintenanceBtn: boolean; draining: boolean = false; - bsModalRef: NgbModalRef; + bsModalRef?: HostModalRef; icons = Icons; private tableContext: CdTableFetchDataContext = null; @@ -111,25 +103,27 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit }; orchStatus: OrchestratorStatus; - actionOrchFeatures = { - add: [OrchestratorFeature.HOST_ADD], - edit: [OrchestratorFeature.HOST_LABEL_ADD, OrchestratorFeature.HOST_LABEL_REMOVE], - remove: [OrchestratorFeature.HOST_REMOVE], - maintenance: [ + actionOrchFeatures: Record = { + [ActionLabels.ADD]: [OrchestratorFeature.HOST_ADD], + [ActionLabels.EDIT]: [ + OrchestratorFeature.HOST_LABEL_ADD, + OrchestratorFeature.HOST_LABEL_REMOVE + ], + [ActionLabels.REMOVE]: [OrchestratorFeature.HOST_REMOVE], + [ActionLabels.MAINTENANCE]: [ OrchestratorFeature.HOST_MAINTENANCE_ENTER, OrchestratorFeature.HOST_MAINTENANCE_EXIT ], - drain: [OrchestratorFeature.HOST_DRAIN] + [ActionLabels.DRAIN]: [OrchestratorFeature.HOST_DRAIN] }; constructor( private authStorageService: AuthStorageService, private emptyPipe: EmptyPipe, private hostService: HostService, + private hostActionService: HostActionService, private actionLabels: ActionLabelsI18n, - private taskWrapper: TaskWrapperService, private router: Router, - private notificationService: NotificationService, private orchService: OrchestratorService, private cdsModalService: ModalCdsService ) { @@ -160,14 +154,14 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit : (this.bsModalRef = this.cdsModalService.show(HostFormComponent, { hideMaintenance: this.hideMaintenance })), - disable: (selection: CdTableSelection) => this.getDisable('add', selection) + disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.ADD, selection) }, { name: this.actionLabels.EDIT, permission: 'update', icon: Icons.edit, click: () => this.editAction(), - disable: (selection: CdTableSelection) => this.getDisable('edit', selection) + disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.EDIT, selection) }, { name: this.actionLabels.START_DRAIN, @@ -188,7 +182,7 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit permission: 'delete', icon: Icons.destroy, click: () => this.deleteAction(), - disable: (selection: CdTableSelection) => this.getDisable('remove', selection) + disable: (selection: CdTableSelection) => this.getDisable(ActionLabels.REMOVE, selection) }, { name: this.actionLabels.ENTER_MAINTENANCE, @@ -196,7 +190,7 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit icon: Icons.enter, click: () => this.hostMaintenance(), disable: (selection: CdTableSelection) => - this.getDisable('maintenance', selection) || + this.getDisable(ActionLabels.MAINTENANCE, selection) || this.isExecuting || this.enableMaintenanceBtn, visible: () => !this.showGeneralActionsOnly && !this.enableMaintenanceBtn @@ -207,7 +201,7 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit icon: Icons.exit, click: () => this.hostMaintenance(), disable: (selection: CdTableSelection) => - this.getDisable('maintenance', selection) || + this.getDisable(ActionLabels.MAINTENANCE, selection) || this.isExecuting || !this.enableMaintenanceBtn, visible: () => !this.showGeneralActionsOnly && this.enableMaintenanceBtn @@ -315,171 +309,52 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit } editAction() { - const host = this.selection.first(); - this.hostService.getLabels().subscribe((resp) => { - const hostLabels: string[] = Array.isArray(host['labels']) - ? [...(host['labels'] as string[])] - : []; - const labels = new Set(resp.concat(this.hostService.predefinedLabels).concat(hostLabels)); - const allLabels = Array.from(labels).map((label) => { - return { content: label, selected: hostLabels.includes(label) }; - }); - this.cdsModalService.show(FormModalComponent, { - titleText: $localize`Edit Host: ${host.hostname}`, - fields: [ - { - type: 'select-badges', - name: 'labels', - value: hostLabels, - label: $localize`Labels`, - typeConfig: { - customBadges: true, - options: allLabels, - messages: new SelectMessages({ - empty: $localize`There are no labels.`, - filter: $localize`Filter or add labels`, - add: $localize`Add label` - }) - } - } - ], - submitButtonText: $localize`Edit Host`, - onSubmit: (values: any) => { - this.hostService.update(host['hostname'], true, values.labels).subscribe(() => { - const selectedHost = this.selection.first(); - if (selectedHost && selectedHost['hostname'] === host.hostname) { - host['labels'] = values.labels; - Object.assign(selectedHost, host); - } - this.notificationService.show( - NotificationType.success, - $localize`Updated Host "${host.hostname}"` - ); - // Reload the data table content. - this.table.refreshBtn(); - }); - } - }); + const host = this.selection.first() as Host; + this.hostActionService.openEditModal(host, (labels: string[]) => { + const selectedHost = this.selection.first(); + if (selectedHost && selectedHost['hostname'] === host.hostname) { + host.labels = labels; + Object.assign(selectedHost, host); + } + // Reload the data table content. + this.table.refreshBtn(); }); } hostMaintenance() { - this.isExecuting = true; - const host = this.selection.first(); - if (host['status'] !== 'maintenance') { - this.hostService.update(host['hostname'], false, [], true).subscribe( - () => { - this.isExecuting = false; - this.notificationService.show( - NotificationType.success, - $localize`"${host.hostname}" moved to maintenance` - ); - this.table.refreshBtn(); - }, - (error) => { - this.isExecuting = false; - this.errorMessage = error.error['detail'].split(/\n/); - error.preventDefault(); - if ( - error.error['detail'].includes('WARNING') && - !error.error['detail'].includes('It is NOT safe to stop') && - !error.error['detail'].includes('ALERT') && - !error.error['detail'].includes('unsafe to stop') - ) { - const modalVariables = { - titleText: $localize`Warning`, - buttonText: $localize`Continue`, - warning: true, - bodyTpl: this.maintenanceConfirmTpl, - showSubmit: true, - onSubmit: () => { - this.hostService.update(host['hostname'], false, [], true, true).subscribe( - () => this.cdsModalService.dismissAll(), - () => this.cdsModalService.dismissAll() - ); - } - }; - this.modalRef = this.cdsModalService.show(ConfirmationModalComponent, modalVariables); - } else { - this.notificationService.show( - NotificationType.error, - $localize`"${host.hostname}" cannot be put into maintenance`, - $localize`${error.error['detail']}` - ); - } - } - ); - } else { - this.hostService.update(host['hostname'], false, [], true).subscribe(() => { - this.isExecuting = false; - this.notificationService.show( - NotificationType.success, - $localize`"${host.hostname}" has exited maintenance` - ); - this.table.refreshBtn(); - }); - } + const host = this.selection.first() as Host; + this.hostActionService.hostMaintenance( + host, + this.maintenanceConfirmTpl, + (isExecuting: boolean) => (this.isExecuting = isExecuting), + (errorMessage: string[]) => (this.errorMessage = errorMessage), + () => this.table.refreshBtn(), + () => undefined, + (modalRef: HostModalRef) => { + this.modalRef = modalRef; + } + ); } hostDrain(stop = false) { - const host = this.selection.first(); - if (stop) { - const index = host['labels'].indexOf('_no_schedule', 0); - host['labels'].splice(index, 1); - this.hostService.update(host['hostname'], true, host['labels']).subscribe(() => { - this.notificationService.show( - NotificationType.info, - $localize`"${host['hostname']}" stopped draining` - ); - this.table.refreshBtn(); - }); - } else { - this.hostService.update(host['hostname'], false, [], false, false, true).subscribe(() => { - this.notificationService.show( - NotificationType.info, - $localize`"${host['hostname']}" started draining` - ); - this.table.refreshBtn(); - }); - } + const host = this.selection.first() as Host; + this.hostActionService.hostDrain(host, stop, () => this.table.refreshBtn()); } - getDisable( - action: 'add' | 'edit' | 'remove' | 'maintenance' | 'drain', - selection: CdTableSelection - ): boolean | string { - if ( - action === 'remove' || - action === 'edit' || - action === 'maintenance' || - action === 'drain' - ) { - if (!selection?.hasSingleSelection) { - return true; - } - if (!_.every(selection.selected, 'sources.orchestrator')) { - return this.messages.nonOrchHost; - } - } - return this.orchService.getTableActionDisableDesc( + getDisable(action: string, selection: CdTableSelection): boolean | string { + return this.hostService.getDisable( + action, + selection, this.orchStatus, - this.actionOrchFeatures[action] + this.actionOrchFeatures, + this.messages.nonOrchHost, + [ActionLabels.REMOVE, ActionLabels.EDIT, ActionLabels.MAINTENANCE, ActionLabels.DRAIN] ); } deleteAction() { const hostname = this.selection.first().hostname; - this.modalRef = this.cdsModalService.show(DeleteConfirmationModalComponent, { - impact: DeletionImpact.high, - itemDescription: 'Host', - itemNames: [hostname], - actionDescription: 'remove', - submitActionObservable: () => - this.taskWrapper.wrapTaskAroundCall({ - task: new FinishedTask('host/remove', { hostname: hostname }), - call: this.hostService.delete(hostname) - }) - }); + this.modalRef = this.hostActionService.deleteAction(hostname); } checkHostsFactsAvailable() { @@ -496,10 +371,12 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit transformHostsData() { if (this.checkHostsFactsAvailable()) { _.forEach(this.hosts, (hostKey) => { - hostKey['memory_total_bytes'] = this.emptyPipe.transform(hostKey['memory_total_kb'] * 1024); - hostKey['raw_capacity'] = this.emptyPipe.transform( - hostKey['hdd_capacity_bytes'] + hostKey['flash_capacity_bytes'] - ); + const hostFacts = hostKey as HostFactsCapacitySource; + const totalMemoryBytes = this.hostService.getTotalMemoryBytes(hostFacts); + const rawCapacityBytes = this.hostService.getRawCapacityBytes(hostFacts); + + hostKey['memory_total_bytes'] = this.emptyPipe.transform(totalMemoryBytes); + hostKey['raw_capacity'] = this.emptyPipe.transform(rawCapacityBytes); }); } else { // mark host facts columns unavailable diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html index 14b1c4ef4ee..5302fde889e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html @@ -1,6 +1,8 @@ - Physical Disks + @if (showHeading) { + Physical Disks + }
{ let service: HostService; let httpTesting: HttpTestingController; + const deviceServiceStub = { + prepareDevice: (device: any) => device + }; + + const orchestratorServiceStub = { + getTableActionDisableDesc: jasmine.createSpy('getTableActionDisableDesc').and.returnValue(false) + }; + configureTestBed({ - providers: [HostService], + providers: [ + HostService, + { provide: DeviceService, useValue: deviceServiceStub }, + { provide: OrchestratorService, useValue: orchestratorServiceStub } + ], imports: [HttpClientTestingModule] }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts index 57a10ba7296..e1d408a70ee 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts @@ -8,14 +8,27 @@ import { map, mergeMap, toArray } from 'rxjs/operators'; import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model'; import { InventoryHost } from '~/app/ceph/cluster/inventory/inventory-host.model'; import { ApiClient } from '~/app/shared/api/api-client'; +import { OrchestratorService } from '~/app/shared/api/orchestrator.service'; import { CdHelperClass } from '~/app/shared/classes/cd-helper.class'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { Daemon } from '../models/daemon.interface'; import { CdDevice } from '../models/devices'; import { SmartDataResponseV1 } from '../models/smart'; import { DeviceService } from '../services/device.service'; import { Host } from '../models/host.interface'; +import { OrchestratorFeature } from '../models/orchestrator.enum'; import { OrchestratorStatus } from '../models/orchestrator.interface'; +export interface HostModalRef { + [key: string]: unknown; +} + +export interface HostFactsCapacitySource { + memory_total_kb?: number | string; + hdd_capacity_bytes?: number | string; + flash_capacity_bytes?: number | string; +} + @Injectable({ providedIn: 'root' }) @@ -27,7 +40,8 @@ export class HostService extends ApiClient { constructor( private http: HttpClient, - private deviceService: DeviceService + private deviceService: DeviceService, + private orchService: OrchestratorService ) { super(); } @@ -173,6 +187,55 @@ export class HostService extends ApiClient { return this.http.get(`${this.baseUIURL}/list`); } + /** + * Returns total memory in bytes when memory_total_kb is available. + */ + getTotalMemoryBytes(host?: HostFactsCapacitySource): number | undefined { + const memoryKb = Number(host?.memory_total_kb); + if (!Number.isFinite(memoryKb)) { + return undefined; + } + return memoryKb * 1024; + } + + /** + * Returns raw capacity in bytes when both HDD and flash capacities are available. + */ + getRawCapacityBytes(host?: HostFactsCapacitySource): number | undefined { + const hdd = Number(host?.hdd_capacity_bytes); + const flash = Number(host?.flash_capacity_bytes); + if (!Number.isFinite(hdd) || !Number.isFinite(flash)) { + return undefined; + } + return hdd + flash; + } + + getDisable( + action: TAction, + selection: CdTableSelection, + orchStatus: OrchestratorStatus | undefined, + actionOrchFeatures: Record, + nonOrchHostMessage: string, + requireSingleSelectionActions: TAction[] + ): boolean | string { + if (requireSingleSelectionActions.includes(action)) { + if (!selection?.hasSingleSelection) { + return true; + } + if (!_.every(selection.selected, 'sources.orchestrator')) { + return nonOrchHostMessage; + } + } + + return this.orchService.getTableActionDisableDesc( + orchStatus as OrchestratorStatus, + actionOrchFeatures[action] + ); + } + + /** + * Returns whether host facts are available from the orchestrator. + */ checkHostsFactsAvailable(orchStatus: OrchestratorStatus) { if (orchStatus?.available) { return true; 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 fd81525f009..b56b4da806b 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 @@ -138,6 +138,7 @@ 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 { OverviewComponent } from './resource-overview-card/resource-overview-card.component'; @NgModule({ imports: [ @@ -235,7 +236,8 @@ import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.com TearsheetStepComponent, PageHeaderComponent, SidebarLayoutComponent, - NumberWithUnitComponent + NumberWithUnitComponent, + OverviewComponent ], providers: [provideCharts(withDefaultRegisterables())], exports: [ @@ -282,6 +284,7 @@ import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.com PageHeaderComponent, SidebarLayoutComponent, NumberWithUnitComponent, + OverviewComponent, ProductiveCardComponent ] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.html new file mode 100644 index 00000000000..2bb10ce579a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.html @@ -0,0 +1,49 @@ + + @if (title) { +
+

{{ title }}

+
+ } + +
+
+ @for (field of fields; track $index) { +
+ {{ field.label }} + + @if (field.type === 'status') { + @let statusMeta = field.status | overviewStatus; + + + + {{ field.value | empty: field.emptyText }} + + + } @else if (field.type === 'tags') { +
+ @for (value of field.values; track $index) { + {{ value | empty: field.emptyText }} + } @empty { + {{ field.emptyText ?? '-' }} + } +
+ } @else { + + {{ field.value | empty: field.emptyText }} + + } +
+ } +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.scss new file mode 100644 index 00000000000..d5dec8cb123 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.scss @@ -0,0 +1,61 @@ +@use '@carbon/layout'; + +.cd-overview-card { + display: block; +} + +.cd-overview-section { + + .cd-overview-section { + border-top: 1px solid var(--cds-border-subtle-01); + } +} + +.cd-overview-grid { + row-gap: layout.$spacing-05; + column-gap: layout.$spacing-05; +} + +.cd-overview-heading { + padding-inline-start: layout.$spacing-06; +} + +.cd-overview-item { + min-width: 0; +} + +.cd-overview-label { + display: block; + color: var(--cds-text-secondary); +} + +.cd-overview-value { + overflow-wrap: break-word; +} + +.cd-overview-tags { + display: flex; + flex-wrap: wrap; + gap: layout.$spacing-03; +} + +.cd-overview-status { + display: inline-flex; + align-items: center; + gap: layout.$spacing-02; +} + +.cd-status-text--success { + color: var(--cds-support-success); +} + +.cd-status-text--warning { + color: var(--cds-support-caution-major); +} + +.cd-status-text--danger { + color: var(--cds-support-error); +} + +.cd-status-text--info { + color: var(--cds-support-info); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.spec.ts new file mode 100644 index 00000000000..7a74ea9174c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.spec.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { OverviewComponent } from './resource-overview-card.component'; +import { PipesModule } from '~/app/shared/pipes/pipes.module'; + +describe('OverviewComponent', () => { + let component: OverviewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OverviewComponent], + imports: [PipesModule], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(OverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Template Rendering', () => { + it('should display the title if provided', () => { + component.title = 'Host Overview'; + fixture.detectChanges(); + + const titleEl = fixture.debugElement.query(By.css('.cds--type-heading-03')); + expect(titleEl).toBeTruthy(); + expect(titleEl.nativeElement.textContent.trim()).toBe('Host Overview'); + }); + + it('should render standard text values correctly', () => { + component.fields = [{ label: 'CPU', value: 'Intel', type: 'text' }]; + fixture.detectChanges(); + + const labelEl = fixture.debugElement.query(By.css('.cd-overview-label')); + const valueEl = fixture.debugElement.query(By.css('.cd-overview-value')); + + expect(labelEl.nativeElement.textContent.trim()).toBe('CPU'); + expect(valueEl.nativeElement.textContent.trim()).toBe('Intel'); + }); + + it('should render tags correctly', () => { + component.fields = [ + { + label: 'Roles', + values: ['mon', 'mgr', 'osd'], + type: 'tags' + } + ]; + fixture.detectChanges(); + + const tags = fixture.debugElement.queryAll(By.css('cds-tag')); + expect(tags.length).toBe(3); + expect(tags[0].nativeElement.textContent.trim()).toBe('mon'); + expect(tags[1].nativeElement.textContent.trim()).toBe('mgr'); + expect(tags[2].nativeElement.textContent.trim()).toBe('osd'); + }); + + it('should render fallback emptyText for tags if values array is empty', () => { + component.fields = [ + { + label: 'Roles', + values: [], + type: 'tags', + emptyText: 'No roles assigned' + } + ]; + fixture.detectChanges(); + + const emptyTextEl = fixture.debugElement.query(By.css('.cd-overview-tags span')); + expect(emptyTextEl.nativeElement.textContent.trim()).toBe('No roles assigned'); + }); + + it('should render status fields as plain text values', () => { + component.fields = [ + { label: 'State1', value: 'Available', type: 'status', status: 'success' }, + { label: 'State2', value: 'Maintenance', type: 'status', status: 'warning' }, + { label: 'State3', value: 'Custom State', type: 'status', status: 'info-circle' } + ]; + fixture.detectChanges(); + + const statusContainers = fixture.debugElement.queryAll(By.css('.cd-overview-status')); + expect(statusContainers.length).toBe(3); + + expect(statusContainers[0].nativeElement.textContent.trim()).toContain('Available'); + expect(statusContainers[1].nativeElement.textContent.trim()).toContain('Maintenance'); + expect(statusContainers[2].nativeElement.textContent.trim()).toContain('Custom State'); + }); + + it('should apply fallback info status class for info statuses', () => { + component.fields = [ + { + label: 'State', + value: 'Custom State', + type: 'status', + status: 'info-circle' + } + ]; + fixture.detectChanges(); + + const statusText = fixture.debugElement.query( + By.css('.cd-overview-status .cd-status-text--info') + ); + expect(statusText).toBeTruthy(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.ts new file mode 100644 index 00000000000..b0cc64dc1ab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/resource-overview-card/resource-overview-card.component.ts @@ -0,0 +1,33 @@ +import { Component, Input } from '@angular/core'; + +export type OverviewValue = string | number | boolean | null | undefined; + +export interface OverviewField { + /* Human-readable label shown for the field. */ + label: string; + /* Single value rendered for text/status fields. */ + value?: OverviewValue; + /* Multiple values rendered when the field uses tag display. */ + values?: OverviewValue[]; + /* Selects how the field value should be presented in the UI. */ + type?: 'text' | 'status' | 'tags'; + /* Visual tone used by status rendering (icon/text styling). */ + status?: 'success' | 'warning' | 'danger' | 'info-circle'; + /* Fallback text shown when the value is empty. */ + emptyText?: string; +} + +@Component({ + selector: 'cd-resource-overview-card', + templateUrl: './resource-overview-card.component.html', + styleUrls: ['./resource-overview-card.component.scss'], + standalone: false +}) +export class OverviewComponent { + /* Title shown at the top of the overview card. */ + @Input() title = ''; + /* Fields rendered in the overview card. */ + @Input() fields: OverviewField[] = []; + /* Number of columns used to layout the fields. */ + @Input() columns = 3; +} 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 cdacb9dd738..86daa75b1ca 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 @@ -1,6 +1,9 @@ @if (title) { }