From 1b4f9f10ca544ed0b32504a492502f87ec673985 Mon Sep 17 00:00:00 2001 From: Syed Ali Ul Hasan Date: Mon, 20 Jul 2026 18:55:52 +0530 Subject: [PATCH] mgr/dashboard: migrated pool table tabs into resource pages - Fixes: https://tracker.ceph.com/issues/76713 Signed-off-by: Syed Ali Ul Hasan --- .../pool/_pool-resource-overview-card.scss | 29 ++ ...ol-capacity-protection-card.component.html | 168 ++++++++++ ...ol-capacity-protection-card.component.scss | 43 +++ ...capacity-protection-card.component.spec.ts | 140 ++++++++ ...pool-capacity-protection-card.component.ts | 13 + .../pool-details/pool-details.component.html | 79 ----- .../pool-details.component.spec.ts | 172 ---------- .../pool-details/pool-details.component.ts | 81 ----- .../pool-io-card/pool-io-card.component.html | 103 ++++++ .../pool-io-card/pool-io-card.component.scss | 14 + .../pool-io-card.component.spec.ts | 134 ++++++++ .../pool-io-card/pool-io-card.component.ts | 13 + .../pool/pool-list/pool-list.component.html | 23 +- .../pool-list/pool-list.component.spec.ts | 33 +- .../pool/pool-list/pool-list.component.ts | 110 ++----- .../pool-resource-breadcrumb.resolver.ts | 14 + .../pool-resource-page.component.html | 74 +++++ .../pool-resource-page.component.scss} | 0 .../pool-resource-page.component.spec.ts | 243 ++++++++++++++ .../pool-resource-page.component.ts | 311 ++++++++++++++++++ .../pool-resource-sidebar.component.html | 52 +++ .../pool-resource-sidebar.component.scss | 65 ++++ .../pool-resource-sidebar.component.spec.ts | 81 +++++ .../pool-resource-sidebar.component.ts | 213 ++++++++++++ .../frontend/src/app/ceph/pool/pool.module.ts | 42 ++- .../frontend/src/app/ceph/pool/pool.ts | 33 ++ .../src/app/shared/api/pool.service.ts | 5 +- .../app/shared/models/pool-overview.model.ts | 32 ++ 28 files changed, 1863 insertions(+), 457 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/_pool-resource-overview-card.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.ts delete mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.html delete mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.spec.ts delete mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-breadcrumb.resolver.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.html rename src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/{pool-details/pool-details.component.scss => pool-resource-page/pool-resource-page.component.scss} (100%) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-overview.model.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/_pool-resource-overview-card.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/_pool-resource-overview-card.scss new file mode 100644 index 000000000000..c12f0d398088 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/_pool-resource-overview-card.scss @@ -0,0 +1,29 @@ +@use '@carbon/layout'; + +@mixin base() { + .pool-overview-card { + display: block; + } + + .pool-overview-panels { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: layout.$spacing-06; + } + + .pool-detail-panel { + padding: layout.$spacing-05; + min-width: 0; + } + + .pool-detail-panel__subtitle { + color: var(--cds-text-secondary); + } + + .pool-detail-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: layout.$spacing-03; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.html new file mode 100644 index 000000000000..c33397a9502c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.html @@ -0,0 +1,168 @@ + +
+
+

+ Capacity +

+

+ Storage utilization +

+ +
+ Usage + {{ overviewModel.usagePercent | empty }} +
+ + @if (overviewModel.usageTotal > 0 && overviewModel.usageUsed !== null) { + + + } @else { + - + } + +
+
+ Used + {{ overviewModel.usedCapacity | empty }} +
+
+ Available + {{ overviewModel.availableCapacity | empty }} +
+
+ Total + {{ overviewModel.totalCapacity | empty }} +
+
+ +
+ Quota limit + {{ overviewModel.quotaLimit | empty }} +
+
+ +
+

+ Data Protection +

+

+ Replication and durability +

+ +
+ Type + {{ overviewModel.typeLabel | empty }} +
+ + @if (overviewModel.isErasure) { +
+
+ K (Split) + {{ overviewModel.erasureK | empty }} +
+
+ M (Chunks) + {{ overviewModel.erasureM | empty }} +
+
+ Total + {{ overviewModel.erasureTotal | empty }} +
+
+ +
+ Plugin + {{ overviewModel.erasurePlugin | empty }} +
+ } @else { +
+
+ Replication size + {{ overviewModel.replicationSize | empty }} +
+
+ Min size + {{ overviewModel.minSize | empty }} +
+
+ } + +
+ +
+ CRUSH rule + {{ overviewModel.crushRuleset | empty }} +
+
+ Failure domain + {{ overviewModel.failureDomain | empty }} +
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.scss new file mode 100644 index 000000000000..dd619bd517b9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.scss @@ -0,0 +1,43 @@ +@use '@carbon/layout'; +@use '../pool-resource-overview-card' as pool-resource-overview-card; + +@include pool-resource-overview-card.base(); + +.pool-detail-panel__usage-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: layout.$spacing-03; + margin-bottom: layout.$spacing-03; +} + +.pool-detail-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: layout.$spacing-04; +} + +.pool-detail-panel:last-child .pool-detail-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.pool-detail-panel:last-child .pool-detail-stats.pool-detail-stats--triple { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.pool-detail-stat-box { + padding: layout.$spacing-04; + display: flex; + flex-direction: column; + gap: layout.$spacing-02; + min-height: 5.5rem; +} + +.pool-detail-divider { + border: 0; + border-top: 1px solid var(--cds-border-subtle-01); +} + +.pool-detail-panel cd-usage-bar { + display: block; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.spec.ts new file mode 100644 index 000000000000..9038e8d43b88 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.spec.ts @@ -0,0 +1,140 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { PoolOverviewModel } from '~/app/shared/models/pool-overview.model'; +import { PoolCapacityProtectionCardComponent } from './pool-capacity-protection-card.component'; + +describe('PoolCapacityProtectionCardComponent', () => { + let component: PoolCapacityProtectionCardComponent; + let fixture: ComponentFixture; + const baseOverviewModel: PoolOverviewModel = { + name: 'test-pool', + type: 'replicated', + dataProtection: 'replica: x3', + applications: ['rbd'], + pgStatus: 'active+clean', + crushRuleset: 'replicated_rule', + usageTotal: 1000, + usageUsed: 250, + usagePercent: '25%', + usedCapacity: '250 B', + availableCapacity: '750 B', + totalCapacity: '1000 B', + quotaLimit: 'No quota', + isErasure: false, + typeLabel: 'replicated', + replicationSize: '3', + minSize: '2', + erasureK: '', + erasureM: '', + erasureTotal: '', + erasurePlugin: '', + failureDomain: 'host', + readThroughput: '0 B/s', + readOps: '0/s', + readOpsChartData: [], + writeThroughput: '0 B/s', + writeOps: '0/s', + writeOpsChartData: [] + }; + + configureTestBed({ + imports: [SharedModule], + declarations: [PoolCapacityProtectionCardComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PoolCapacityProtectionCardComponent); + component = fixture.componentInstance; + component.overviewModel = { ...baseOverviewModel }; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('Capacity Section', () => { + it('should display capacity details and usage bar when usage data is available', () => { + fixture.detectChanges(); + const textContent = fixture.nativeElement.textContent; + + expect(textContent).toContain('Capacity'); + expect(textContent).toContain('25%'); + expect(textContent).toContain('250 B'); + expect(textContent).toContain('750 B'); + expect(textContent).toContain('1000 B'); + expect(textContent).toContain('No quota'); + const usageBar = fixture.nativeElement.querySelector('cd-usage-bar'); + expect(usageBar).toBeTruthy(); + }); + + it('should hide the usage bar and show a dash if usageTotal is 0', () => { + component.overviewModel = { ...baseOverviewModel, usageTotal: 0 }; + fixture.detectChanges(); + + const usageBar = fixture.nativeElement.querySelector('cd-usage-bar'); + expect(usageBar).toBeFalsy(); + }); + + it('should hide the usage bar and show a dash if usageUsed is null', () => { + component.overviewModel = { ...baseOverviewModel, usageUsed: null }; + fixture.detectChanges(); + + const usageBar = fixture.nativeElement.querySelector('cd-usage-bar'); + expect(usageBar).toBeFalsy(); + }); + }); + + describe('Data Protection Section', () => { + it('should display common fields (CRUSH, Failure Domain, Type)', () => { + fixture.detectChanges(); + const textContent = fixture.nativeElement.textContent; + + expect(textContent).toContain('replicated'); + expect(textContent).toContain('replicated_rule'); + expect(textContent).toContain('host'); + }); + + it('should display replicated specific fields when pool is NOT erasure coded', () => { + component.overviewModel = { ...baseOverviewModel, isErasure: false }; + fixture.detectChanges(); + + const textContent = fixture.nativeElement.textContent; + + expect(textContent).toContain('Replication size'); + expect(textContent).toContain('3'); + expect(textContent).toContain('Min size'); + expect(textContent).toContain('2'); + + // Ensure erasure fields are NOT rendered + expect(textContent).not.toContain('K (Split)'); + expect(textContent).not.toContain('M (Chunks)'); + }); + + it('should display erasure coded specific fields when pool is erasure coded', () => { + component.overviewModel = { + ...baseOverviewModel, + isErasure: true, + typeLabel: 'Erasure Coded', + erasureK: '4', + erasureM: '2', + erasureTotal: '6', + erasurePlugin: 'jerasure' + }; + fixture.detectChanges(); + + const textContent = fixture.nativeElement.textContent; + + expect(textContent).toContain('Erasure Coded'); + expect(textContent).toContain('K (Split)'); + expect(textContent).toContain('4'); + expect(textContent).toContain('M (Chunks)'); + expect(textContent).toContain('2'); + expect(textContent).toContain('Plugin'); + expect(textContent).toContain('jerasure'); + expect(textContent).not.toContain('Replication size'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.ts new file mode 100644 index 000000000000..e1dc6d266ccc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-capacity-protection-card/pool-capacity-protection-card.component.ts @@ -0,0 +1,13 @@ +import { Component, Input, ViewEncapsulation } from '@angular/core'; +import { PoolOverviewModel } from '~/app/shared/models/pool-overview.model'; + +@Component({ + selector: 'cd-pool-capacity-protection-card', + templateUrl: './pool-capacity-protection-card.component.html', + styleUrls: ['./pool-capacity-protection-card.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class PoolCapacityProtectionCardComponent { + @Input() overviewModel: PoolOverviewModel; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.html deleted file mode 100644 index ac2ca68e9150..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.html +++ /dev/null @@ -1,79 +0,0 @@ - - - -
-
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.spec.ts deleted file mode 100644 index 40a0ae365a5f..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ChangeDetectorRef } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; - -import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; - -import { RbdConfigurationListComponent } from '~/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component'; -import { Permissions } from '~/app/shared/models/permissions'; -import { SharedModule } from '~/app/shared/shared.module'; -import { configureTestBed, Mocks, TabHelper } from '~/testing/unit-test-helper'; -import { PoolDetailsComponent } from './pool-details.component'; - -describe('PoolDetailsComponent', () => { - let poolDetailsComponent: PoolDetailsComponent; - let fixture: ComponentFixture; - - // Needed because of ChangeDetectionStrategy.OnPush - // https://github.com/angular/angular/issues/12313#issuecomment-444623173 - let changeDetector: ChangeDetectorRef; - const detectChanges = () => { - poolDetailsComponent.ngOnChanges(); - changeDetector.detectChanges(); // won't call ngOnChanges on it's own but updates fixture - }; - - const updatePoolSelection = (selection: any) => { - poolDetailsComponent.selection = selection; - detectChanges(); - }; - - const currentPoolUpdate = () => { - updatePoolSelection(poolDetailsComponent.selection); - }; - - configureTestBed({ - imports: [ - BrowserAnimationsModule, - NgbNavModule, - SharedModule, - HttpClientTestingModule, - RouterTestingModule - ], - declarations: [PoolDetailsComponent, RbdConfigurationListComponent] - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PoolDetailsComponent); - // Needed because of ChangeDetectionStrategy.OnPush - // https://github.com/angular/angular/issues/12313#issuecomment-444623173 - changeDetector = fixture.componentRef.injector.get(ChangeDetectorRef); - poolDetailsComponent = fixture.componentInstance; - poolDetailsComponent.selection = undefined; - poolDetailsComponent.permissions = new Permissions({ - grafana: ['read'] - }); - updatePoolSelection({ tiers: [0], pool: 0, pool_name: 'micro_pool' }); - }); - - it('should create', () => { - expect(poolDetailsComponent).toBeTruthy(); - }); - - describe('Pool details tabset', () => { - it('should recognize a tabset child', () => { - detectChanges(); - const ngbNav = TabHelper.getNgbNav(fixture); - expect(ngbNav).toBeDefined(); - }); - - it('should not change the tabs active status when selection is the same as before', () => { - const tabs = TabHelper.getNgbNavItems(fixture); - expect(tabs[0].active).toBeTruthy(); - currentPoolUpdate(); - expect(tabs[0].active).toBeTruthy(); - - const ngbNav = TabHelper.getNgbNav(fixture); - ngbNav.select(tabs[1].id); - expect(tabs[1].active).toBeTruthy(); - currentPoolUpdate(); - expect(tabs[1].active).toBeTruthy(); - }); - - it('should filter out cdExecuting, cdIsBinary and all stats', () => { - updatePoolSelection({ - prop1: 1, - cdIsBinary: true, - prop2: 2, - cdExecuting: true, - prop3: 3, - stats: { anyStat: 3, otherStat: [1, 2, 3] } - }); - const expectedPool = { prop1: 1, prop2: 2, prop3: 3 }; - expect(poolDetailsComponent.poolDetails).toEqual(expectedPool); - }); - - describe('Updates of shown data', () => { - const expectedChange = ( - expected: { - selectedPoolConfiguration?: object; - poolDetails?: object; - }, - newSelection: object, - doesNotEqualOld = true - ) => { - const getData = () => { - const data = {}; - Object.keys(expected).forEach((key) => (data[key] = poolDetailsComponent[key])); - return data; - }; - const oldData = getData(); - updatePoolSelection(newSelection); - const newData = getData(); - if (doesNotEqualOld) { - expect(expected).not.toEqual(oldData); - } else { - expect(expected).toEqual(oldData); - } - expect(expected).toEqual(newData); - }; - - it('should update shown data on change', () => { - expectedChange( - { - poolDetails: { - application_metadata: ['rbd'], - pg_num: 256, - pg_num_target: 256, - pg_placement_num: 256, - pg_placement_num_target: 256, - pool: 2, - pool_name: 'somePool', - type: 'replicated', - size: 3 - } - }, - Mocks.getPool('somePool', 2) - ); - }); - - it('should not update shown data if no detail has changed on pool refresh', () => { - expectedChange( - { - poolDetails: { - pool: 0, - pool_name: 'micro_pool', - tiers: [0] - } - }, - poolDetailsComponent.selection, - false - ); - }); - - it('should show "Cache Tiers Details" tab if selected pool has "tiers"', () => { - const tabsItem = TabHelper.getNgbNavItems(fixture); - const tabsText = TabHelper.getTextContents(fixture); - expect(poolDetailsComponent.selection['tiers'].length).toBe(1); - expect(tabsItem.length).toBe(3); - expect(tabsText[2]).toBe('Cache Tiers Details'); - expect(tabsItem[0].active).toBeTruthy(); - }); - - it('should not show "Cache Tiers Details" tab if selected pool has no "tiers"', () => { - updatePoolSelection({ tiers: [] }); - const tabs = TabHelper.getNgbNavItems(fixture); - expect(tabs.length).toEqual(2); - expect(tabs[0].active).toBeTruthy(); - }); - }); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.ts deleted file mode 100644 index 8801b1145eef..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; - -import _ from 'lodash'; - -import { PoolService } from '~/app/shared/api/pool.service'; -import { CdHelperClass } from '~/app/shared/classes/cd-helper.class'; -import { CdTableColumn } from '~/app/shared/models/cd-table-column'; -import { RbdConfigurationEntry } from '~/app/shared/models/configuration'; -import { Permissions } from '~/app/shared/models/permissions'; - -@Component({ - selector: 'cd-pool-details', - templateUrl: './pool-details.component.html', - styleUrls: ['./pool-details.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false -}) -export class PoolDetailsComponent implements OnChanges { - @Input() - cacheTiers: any[]; - @Input() - permissions: Permissions; - @Input() - selection: any; - - cacheTierColumns: Array = []; - // 'stats' won't be shown as the pure stat numbers won't tell the user much, - // if they are not converted or used in a chart (like the ones available in the pool listing) - omittedPoolAttributes = ['cdExecuting', 'cdIsBinary', 'stats']; - - poolDetails: object; - selectedPoolConfiguration: RbdConfigurationEntry[]; - - constructor(private poolService: PoolService) { - this.cacheTierColumns = [ - { - prop: 'pool_name', - name: $localize`Name`, - flexGrow: 3 - }, - { - prop: 'cache_mode', - name: $localize`Cache Mode`, - flexGrow: 2 - }, - { - prop: 'cache_min_evict_age', - name: $localize`Min Evict Age`, - flexGrow: 2 - }, - { - prop: 'cache_min_flush_age', - name: $localize`Min Flush Age`, - flexGrow: 2 - }, - { - prop: 'target_max_bytes', - name: $localize`Target Max Bytes`, - flexGrow: 2 - }, - { - prop: 'target_max_objects', - name: $localize`Target Max Objects`, - flexGrow: 2 - } - ]; - } - - ngOnChanges() { - if (this.selection) { - this.poolService - .getConfiguration(this.selection.pool_name) - .subscribe((poolConf: RbdConfigurationEntry[]) => { - CdHelperClass.updateChanged(this, { selectedPoolConfiguration: poolConf }); - }); - CdHelperClass.updateChanged(this, { - poolDetails: _.omit(this.selection, this.omittedPoolAttributes) - }); - } - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.html new file mode 100644 index 000000000000..3a6957a33f99 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.html @@ -0,0 +1,103 @@ + +
+
+

+ Read +

+

+ Performance and activity +

+ +
+ Throughput + {{ overviewModel.readThroughput | empty }} +
+ +
+ Ops + {{ overviewModel.readOps | empty }} +
+ +
+ @if (overviewModel.readOpsChartData?.length) { + + + } @else { + - + } +
+
+ +
+

+ Write +

+

+ Performance and activity +

+ +
+ Throughput + {{ overviewModel.writeThroughput | empty }} +
+ +
+ Ops + {{ overviewModel.writeOps | empty }} +
+ +
+ @if (overviewModel.writeOpsChartData?.length) { + + + } @else { + - + } +
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.scss new file mode 100644 index 000000000000..061eff6a6d7d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.scss @@ -0,0 +1,14 @@ +@use '@carbon/layout'; +@use '../pool-resource-overview-card' as pool-resource-overview-card; + +@include pool-resource-overview-card.base(); + +.pool-detail-chart { + display: flex; + flex-direction: column; + gap: layout.$spacing-03; +} + +.pool-detail-panel cd-area-chart { + display: block; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.spec.ts new file mode 100644 index 000000000000..eb97842bfe10 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.spec.ts @@ -0,0 +1,134 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { PoolOverviewModel } from '~/app/shared/models/pool-overview.model'; +import { ChartPoint } from '~/app/shared/models/area-chart-point'; +import { PoolIoCardComponent } from './pool-io-card.component'; + +describe('PoolIoCardComponent', () => { + let component: PoolIoCardComponent; + let fixture: ComponentFixture; + + const baseOverviewModel: PoolOverviewModel = { + name: 'test-pool', + type: 'replicated', + dataProtection: 'replica: x3', + applications: ['rbd'], + pgStatus: 'active+clean', + crushRuleset: 'replicated_rule', + usageTotal: 1000, + usageUsed: 250, + usagePercent: '25%', + usedCapacity: '250 B', + availableCapacity: '750 B', + totalCapacity: '1000 B', + quotaLimit: 'No quota', + isErasure: false, + typeLabel: 'replicated', + replicationSize: '3', + minSize: '2', + erasureK: '', + erasureM: '', + erasureTotal: '', + erasurePlugin: '', + failureDomain: 'host', + readThroughput: '1.5 KiB/s', + readOps: '15/s', + readOpsChartData: [], + writeThroughput: '2.5 KiB/s', + writeOps: '25/s', + writeOpsChartData: [] + }; + + const dummyChartData: ChartPoint[] = [{ timestamp: new Date(), values: { Ops: 10 } }]; + + configureTestBed({ + imports: [SharedModule], + declarations: [PoolIoCardComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PoolIoCardComponent); + component = fixture.componentInstance; + component.overviewModel = { ...baseOverviewModel }; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('Read Section', () => { + it('should display read metrics', () => { + fixture.detectChanges(); + const textContent = fixture.nativeElement.textContent; + + expect(textContent).toContain('Throughput'); + expect(textContent).toContain('1.5 KiB/s'); + expect(textContent).toContain('Ops'); + expect(textContent).toContain('15/s'); + }); + + it('should hide the read chart and show a dash when chart data is empty', () => { + fixture.detectChanges(); + const chartContainers = fixture.nativeElement.querySelectorAll('.pool-detail-chart'); + expect(chartContainers[0].textContent?.trim()).toBe('-'); + }); + + it('should show the read chart when chart data is provided', () => { + component.overviewModel = { + ...baseOverviewModel, + readOpsChartData: dummyChartData + }; + fixture.detectChanges(); + + const areaCharts = fixture.nativeElement.querySelectorAll('cd-area-chart'); + expect(areaCharts.length).toBe(1); + expect(areaCharts[0].getAttribute('chartTitle')).toBe('Read Ops'); + }); + }); + + describe('Write Section', () => { + it('should display write metrics', () => { + fixture.detectChanges(); + const textContent = fixture.nativeElement.textContent; + + expect(textContent).toContain('Throughput'); + expect(textContent).toContain('2.5 KiB/s'); + expect(textContent).toContain('Ops'); + expect(textContent).toContain('25/s'); + }); + + it('should hide the write chart and show a dash when chart data is empty', () => { + fixture.detectChanges(); + + const chartContainers = fixture.nativeElement.querySelectorAll('.pool-detail-chart'); + expect(chartContainers[1].textContent?.trim()).toBe('-'); + }); + + it('should show the write chart when chart data is provided', () => { + component.overviewModel = { + ...baseOverviewModel, + writeOpsChartData: dummyChartData + }; + fixture.detectChanges(); + + const areaCharts = fixture.nativeElement.querySelectorAll('cd-area-chart'); + expect(areaCharts.length).toBe(1); + expect(areaCharts[0].getAttribute('chartTitle')).toBe('Write Ops'); + }); + }); + + it('should display both charts when both sets of data are provided', () => { + component.overviewModel = { + ...baseOverviewModel, + readOpsChartData: dummyChartData, + writeOpsChartData: dummyChartData + }; + fixture.detectChanges(); + + const areaCharts = fixture.nativeElement.querySelectorAll('cd-area-chart'); + expect(areaCharts.length).toBe(2); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.ts new file mode 100644 index 000000000000..bf1191c2843c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-io-card/pool-io-card.component.ts @@ -0,0 +1,13 @@ +import { Component, Input, ViewEncapsulation } from '@angular/core'; +import { PoolOverviewModel } from '~/app/shared/models/pool-overview.model'; + +@Component({ + selector: 'cd-pool-io-card', + templateUrl: './pool-io-card.component.html', + styleUrls: ['./pool-io-card.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class PoolIoCardComponent { + @Input() overviewModel: PoolOverviewModel; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html index 76f32464202c..8a2fda126ca5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html @@ -16,11 +16,9 @@ [data]="pools" [columns]="columns" selectionType="single" - [hasDetails]="true" [status]="tableStatus" [autoReload]="-1" (fetchData)="taskListService.fetch()" - (setExpandedRow)="setExpandedRow($event)" (updateSelection)="updateSelection($event)" > - - @@ -81,3 +71,16 @@ > + + + + {{ row.pool_name }} + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts index f0f009e70400..531dd8c53c4e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts @@ -22,8 +22,7 @@ import { SummaryService } from '~/app/shared/services/summary.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { SharedModule } from '~/app/shared/shared.module'; import { configureTestBed, expectItemTasks, Mocks } from '~/testing/unit-test-helper'; -import { Pool } from '../pool'; -import { PoolDetailsComponent } from '../pool-details/pool-details.component'; +import { Pool, transformPgStatus } from '../pool'; import { PoolListComponent } from './pool-list.component'; describe('PoolListComponent', () => { @@ -45,7 +44,7 @@ describe('PoolListComponent', () => { }; configureTestBed({ - declarations: [PoolListComponent, PoolDetailsComponent, RbdConfigurationListComponent], + declarations: [PoolListComponent, RbdConfigurationListComponent], imports: [ BrowserAnimationsModule, SharedModule, @@ -282,26 +281,6 @@ describe('PoolListComponent', () => { }); }); - describe('custom row comparators', () => { - const expectCorrectComparator = (statsAttribute: string) => { - const mockPool = (v: number) => ({ stats: { [statsAttribute]: { latest: v } } }); - const columnDefinition = _.find( - component.columns, - (column) => column.prop === `stats.${statsAttribute}.rates` - ); - expect(columnDefinition.comparator(undefined, undefined, mockPool(2), mockPool(1))).toBe(1); - expect(columnDefinition.comparator(undefined, undefined, mockPool(1), mockPool(2))).toBe(-1); - }; - - it('compares read bytes correctly', () => { - expectCorrectComparator('rd_bytes'); - }); - - it('compares write bytes correctly', () => { - expectCorrectComparator('wr_bytes'); - }); - }); - describe('transformPoolsData', () => { let pool: Pool; @@ -436,28 +415,28 @@ describe('PoolListComponent', () => { const pgStatus = { 'active+clean': 8 }; const expected = '8 active+clean'; - expect(component.transformPgStatus(pgStatus)).toEqual(expected); + expect(transformPgStatus(pgStatus)).toEqual(expected); }); it('returns separated status groups', () => { const pgStatus = { 'active+clean': 8, down: 2 }; const expected = '8 active+clean, 2 down'; - expect(component.transformPgStatus(pgStatus)).toEqual(expected); + expect(transformPgStatus(pgStatus)).toEqual(expected); }); it('returns separated statuses correctly', () => { const pgStatus = { active: 8, down: 2 }; const expected = '8 active, 2 down'; - expect(component.transformPgStatus(pgStatus)).toEqual(expected); + expect(transformPgStatus(pgStatus)).toEqual(expected); }); it('returns empty string', () => { const pgStatus: any = undefined; const expected = ''; - expect(component.transformPgStatus(pgStatus)).toEqual(expected); + expect(transformPgStatus(pgStatus)).toEqual(expected); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts index 1c773ab637f2..4b54fccc0a42 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts @@ -22,18 +22,21 @@ import { ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile'; import { ExecutingTask } from '~/app/shared/models/executing-task'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { Permissions } from '~/app/shared/models/permissions'; -import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { TaskListService } from '~/app/shared/services/task-list.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; -import { Pool, PoolType } from '../pool'; +import { mapPoolApplications, Pool, PoolType, transformPgStatus } from '../pool'; import { PoolStat, PoolStats } from '../pool-stat'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; const BASE_URL = 'pool'; +interface PoolTaskMetadata { + pool_name: string; +} + @Component({ selector: 'cd-pool-list', templateUrl: './pool-list.component.html', @@ -46,23 +49,24 @@ const BASE_URL = 'pool'; }) export class PoolListComponent extends ListWithDetails implements OnInit { @ViewChild(TableComponent) - table: TableComponent; - @ViewChild('poolUsageTpl', { static: true }) - poolUsageTpl: TemplateRef; + table!: TableComponent; + @ViewChild('poolNameTpl', { static: true }) + poolNameTpl!: TemplateRef; @ViewChild('poolConfigurationSourceTpl') - poolConfigurationSourceTpl: TemplateRef; + poolConfigurationSourceTpl!: TemplateRef; - pools: Pool[]; - columns: CdTableColumn[]; + pools: Pool[] = []; + columns: CdTableColumn[] = []; selection = new CdTableSelection(); executingTasks: ExecutingTask[] = []; permissions: Permissions; - tableActions: CdTableAction[]; + tableActions: CdTableAction[] = []; tableStatus = new TableStatusViewCache(); cacheTiers: any[] = []; monAllowPoolDelete = false; - ecProfileList: ErasureCodeProfile[]; + ecProfileList: ErasureCodeProfile[] = []; + viewUrl = '/pool/view'; constructor( private poolService: PoolService, @@ -72,7 +76,6 @@ export class PoolListComponent extends ListWithDetails implements OnInit { public taskListService: TaskListService, private modalService: ModalCdsService, private pgCategoryService: PgCategoryService, - private dimlessPipe: DimlessPipe, private urlBuilder: URLBuilderService, private configurationService: ConfigurationService, public actionLabels: ActionLabelsI18n @@ -125,14 +128,12 @@ export class PoolListComponent extends ListWithDetails implements OnInit { } ngOnInit() { - const compare = (prop: string, pool1: Pool, pool2: Pool) => - _.get(pool1, prop) > _.get(pool2, prop) ? 1 : -1; this.columns = [ { prop: 'pool_name', name: $localize`Name`, flexGrow: 2, - cellTransformation: CellTemplate.executing + cellTemplate: this.poolNameTpl }, { prop: 'data_protection', @@ -159,48 +160,6 @@ export class PoolListComponent extends ListWithDetails implements OnInit { cellClass: ({ row, column, value }): any => { return this.getPgStatusCellClass(row, column, value); } - }, - { - prop: 'crush_rule', - name: $localize`Crush Ruleset`, - isHidden: true, - flexGrow: 2 - }, - { - name: $localize`Usage`, - prop: 'usage', - cellTemplate: this.poolUsageTpl, - flexGrow: 1.2 - }, - { - prop: 'stats.rd_bytes.rates', - name: $localize`Read bytes`, - comparator: (_valueA: any, _valueB: any, rowA: Pool, rowB: Pool) => - compare('stats.rd_bytes.latest', rowA, rowB), - cellTransformation: CellTemplate.sparkline, - flexGrow: 1.5 - }, - { - prop: 'stats.wr_bytes.rates', - name: $localize`Write bytes`, - comparator: (_valueA: any, _valueB: any, rowA: Pool, rowB: Pool) => - compare('stats.wr_bytes.latest', rowA, rowB), - cellTransformation: CellTemplate.sparkline, - flexGrow: 1.5 - }, - { - prop: 'stats.rd.rate', - name: $localize`Read ops`, - flexGrow: 1, - pipe: this.dimlessPipe, - cellTransformation: CellTemplate.perSecond - }, - { - prop: 'stats.wr.rate', - name: $localize`Write ops`, - flexGrow: 1, - pipe: this.dimlessPipe, - cellTransformation: CellTemplate.perSecond } ]; @@ -212,8 +171,8 @@ export class PoolListComponent extends ListWithDetails implements OnInit { return this.poolService.getList(); }) ), - undefined, - (pools) => { + (resp: Pool[]) => resp, + (pools: Pool[]) => { this.pools = this.transformPoolsData(pools); this.tableStatus = new TableStatusViewCache(); }, @@ -222,8 +181,8 @@ export class PoolListComponent extends ListWithDetails implements OnInit { this.tableStatus = new TableStatusViewCache(ViewCacheStatus.ValueException); }, (task) => task.name.startsWith(`${BASE_URL}/`), - (pool, task) => task.metadata['pool_name'] === pool.pool_name, - { default: (metadata: any) => new Pool(metadata['pool_name']) } + (pool: Pool, task) => (task.metadata as PoolTaskMetadata).pool_name === pool.pool_name, + { default: (metadata: PoolTaskMetadata) => new Pool(metadata.pool_name) } ); } @@ -262,7 +221,7 @@ export class PoolListComponent extends ListWithDetails implements OnInit { return ecpInfo; } - transformPoolsData(pools: any) { + transformPoolsData(pools: Pool[]): Pool[] { const requiredStats = [ 'bytes_used', 'max_avail', @@ -274,20 +233,15 @@ export class PoolListComponent extends ListWithDetails implements OnInit { 'wr' ]; const emptyStat: PoolStat = { latest: 0, rate: 0, rates: [] }; - const applicationLabels: Record = { - cephfs: $localize`File system`, - rbd: $localize`Block`, - rgw: $localize`Object` - }; _.forEach(pools, (pool: Pool) => { - pool['pg_status'] = this.transformPgStatus(pool['pg_status']); + pool['pg_status'] = transformPgStatus(pool['pg_status']); const stats: PoolStats = {}; _.forEach(requiredStats, (stat) => { - stats[stat] = pool.stats && pool.stats[stat] ? pool.stats[stat] : emptyStat; + stats[stat] = pool.stats?.[stat] ? pool.stats[stat] : emptyStat; }); pool['stats'] = stats; - pool['usage'] = stats.percent_used.latest; + pool['usage'] = stats.percent_used?.latest ?? 0; if ( !pool.cdExecuting && @@ -297,7 +251,10 @@ export class PoolListComponent extends ListWithDetails implements OnInit { } ['rd_bytes', 'wr_bytes'].forEach((stat) => { - pool.stats[stat].rates = pool.stats[stat].rates.map((point: any) => point[1]); + const statData = pool.stats?.[stat] || emptyStat; + statData.rates = statData.rates.map((point: any) => point[1]); + const poolStats = pool.stats || (pool.stats = {} as PoolStats); + poolStats[stat] = statData; }); pool.cdIsBinary = true; @@ -309,23 +266,12 @@ export class PoolListComponent extends ListWithDetails implements OnInit { pool['data_protection'] = `replica: ×${pool['size']}`; } - pool['application_metadata'] = (pool.application_metadata || []).map( - (application: string) => applicationLabels[application] || application - ); + pool['application_metadata'] = mapPoolApplications(pool.application_metadata || []); }); return pools; } - transformPgStatus(pgStatus: any): string { - const strings: string[] = []; - _.forEach(pgStatus, (count, state) => { - strings.push(`${count} ${state}`); - }); - - return strings.join(', '); - } - getSelectionTiers() { if (typeof this.expandedRow !== 'undefined') { const cacheTierIds = this.expandedRow['tiers']; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-breadcrumb.resolver.ts new file mode 100644 index 000000000000..917bd0a0dc29 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-breadcrumb.resolver.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs'; + +@Injectable({ + providedIn: 'root' +}) +export class PoolResourceBreadcrumbResolver extends BreadcrumbsResolver { + resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] { + const name = route.parent?.params?.name || route.params?.name || ''; + return [{ text: name, path: this.getFullPath(route) }]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.html new file mode 100644 index 000000000000..4e6f257c4d30 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.html @@ -0,0 +1,74 @@ +@if (poolName) { + @switch (section) { + @case ('overview') { + + + + + + + + } + @case ('performance') { + + + } + @case ('configuration') { +
+

+ Block Configuration +

+

+ View the current block settings and defaults configured for this pool. +

+ +
+ +
+

+ Cache-Tiers Details +

+

+ View cache tier relationships and settings associated with this pool. +

+ + +
+ } + } +} @else { + No pool name found. +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.scss similarity index 100% rename from src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.scss rename to src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.spec.ts new file mode 100644 index 000000000000..caebff826396 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.spec.ts @@ -0,0 +1,243 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { of, Subject } from 'rxjs'; + +import { PoolResourcePageComponent } from './pool-resource-page.component'; +import { PoolService } from '~/app/shared/api/pool.service'; +import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile.service'; +import { FormatterService } from '~/app/shared/services/formatter.service'; +import { PoolType } from '../pool'; + +describe('PoolResourcePageComponent', () => { + let component: PoolResourcePageComponent; + let fixture: ComponentFixture; + + let mockPoolService: { get: jest.Mock; getConfiguration: jest.Mock; list: jest.Mock }; + let mockEcpService: { list: jest.Mock }; + let mockFormatterService: { format_number: jest.Mock }; + let paramMapSubject: Subject; + let mockActivatedRoute: any; + + const mockPoolData = { + pool: 1, + pool_name: 'test-pool', + type: PoolType.REPLICATED, + size: 3, + min_size: 2, + crush_rule: 'replicated_rule', + pg_status: { 'active+clean': 32 }, + application_metadata: ['rbd'], + quota_max_bytes: 1024000, + tiers: [2], + stats: { + bytes_used: { latest: 500, rate: 0, rates: [] }, + avail_raw: { latest: 1500, rate: 0, rates: [] }, + rd_bytes: { latest: 100, rate: 50, rates: [[1620000000, 50]] }, + rd: { latest: 10, rate: 5, rates: [[1620000000, 50]] }, + wr: { latest: 200, rate: 10, rates: [10] } + } + }; + + const mockLightweightPoolList = [ + { pool: 1, pool_name: 'test-pool' }, + { pool: 2, pool_name: 'cache-pool', cache_mode: 'writeback' } + ]; + + const mockConfigData = [{ name: 'rbd_qos_bps_limit', value: '1000' }]; + + const mockEcpData = [ + { name: 'default', k: 2, m: 1, plugin: 'jerasure' }, + { name: 'ec-profile', k: 4, m: 2, plugin: 'isa' } + ]; + + beforeEach(async () => { + mockPoolService = { + get: jest.fn().mockReturnValue(of(mockPoolData)), + getConfiguration: jest.fn().mockReturnValue(of(mockConfigData)), + list: jest.fn().mockReturnValue(of(mockLightweightPoolList)) + }; + + mockEcpService = { + list: jest.fn().mockReturnValue(of(mockEcpData)) + }; + + mockFormatterService = { + format_number: jest.fn().mockImplementation((val: any) => `${val} formatted`) + }; + + paramMapSubject = new Subject(); + mockActivatedRoute = { + parent: { paramMap: paramMapSubject.asObservable() }, + snapshot: { data: { section: 'overview' } } + }; + + await TestBed.configureTestingModule({ + declarations: [PoolResourcePageComponent], + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: PoolService, useValue: mockPoolService }, + { provide: ErasureCodeProfileService, useValue: mockEcpService }, + { provide: FormatterService, useValue: mockFormatterService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PoolResourcePageComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('Data Initialization', () => { + it('should clear fields and not call APIs if poolName is empty', fakeAsync(() => { + fixture.detectChanges(); + paramMapSubject.next(convertToParamMap({ name: null })); + tick(); + + expect(component.poolName).toBe(''); + expect(component.poolOverviewFields.length).toBe(0); + expect(mockPoolService.get).not.toHaveBeenCalled(); + })); + + it('should fetch all required data when poolName is provided', fakeAsync(() => { + fixture.detectChanges(); + paramMapSubject.next(convertToParamMap({ name: 'test-pool' })); + tick(); + + expect(component.poolName).toBe('test-pool'); + expect(mockPoolService.get).toHaveBeenCalledWith('test-pool', true); + expect(mockPoolService.getConfiguration).toHaveBeenCalledWith('test-pool'); + expect(mockPoolService.list).toHaveBeenCalledWith([ + 'pool', + 'pool_name', + 'cache_mode', + 'cache_min_evict_age', + 'cache_min_flush_age', + 'target_max_bytes', + 'target_max_objects' + ]); + expect(mockEcpService.list).toHaveBeenCalled(); + })); + }); + + describe('Data Transformations', () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + paramMapSubject.next(convertToParamMap({ name: 'test-pool' })); + tick(); + })); + + it('should correctly build overview model for a replicated pool', () => { + expect(component.overviewModel.name).toBe('test-pool'); + expect(component.overviewModel.type).toBe(PoolType.REPLICATED); + expect(component.overviewModel.dataProtection).toBe('replica: x3'); + expect(component.overviewModel.pgStatus).toBe('32 active+clean'); + expect(component.overviewModel.usageTotal).toBe(2000); + expect(component.overviewModel.usagePercent).toBe('25%'); + expect(component.overviewModel.usedCapacity).toBe('500 formatted'); + expect(component.overviewModel.replicationSize).toBe('3'); + expect(component.overviewModel.minSize).toBe('2'); + expect(component.overviewModel.isErasure).toBe(false); + }); + + it('should correctly build overview model for an erasure coded pool', fakeAsync(() => { + const ecPoolData = { + ...mockPoolData, + type: PoolType.ERASURE, + erasure_code_profile: 'ec-profile' + }; + mockPoolService.get.mockReturnValue(of(ecPoolData)); + paramMapSubject.next(convertToParamMap({ name: 'test-pool' })); + tick(); + + expect(component.overviewModel.typeLabel).toBe('Erasure Coded'); + expect(component.overviewModel.isErasure).toBe(true); + expect(component.overviewModel.dataProtection).toBe('EC: ec-profile'); + expect(component.overviewModel.erasureK).toBe('4'); + expect(component.overviewModel.erasureM).toBe('2'); + expect(component.overviewModel.erasureTotal).toBe('6'); + expect(component.overviewModel.erasurePlugin).toBe('isa'); + })); + + it('should correctly map cache tiers from the lightweight pool list', () => { + expect(component.cacheTiers.length).toBe(1); + expect(component.cacheTiers[0].pool_name).toBe('cache-pool'); + expect(component.cacheTiers[0].cache_mode).toBe('writeback'); + }); + + it('should correctly process rate chart data (arrays and numbers)', () => { + expect(component.overviewModel.readOpsChartData.length).toBe(1); + expect(component.overviewModel.readOpsChartData[0].values['Read Ops']).toBe(50); + + expect(component.overviewModel.writeOpsChartData.length).toBe(1); + expect(component.overviewModel.writeOpsChartData[0].values['Write Ops']).toBe(10); + }); + }); + + describe('Template Section Rendering', () => { + it('should render the overview section when section is "overview"', fakeAsync(() => { + mockActivatedRoute.snapshot.data.section = 'overview'; + fixture.detectChanges(); + paramMapSubject.next(convertToParamMap({ name: 'test-pool' })); + tick(); + fixture.detectChanges(); + + const el = fixture.nativeElement; + expect(el.querySelector('cd-resource-overview-card')).toBeTruthy(); + expect(el.querySelector('cd-pool-capacity-protection-card')).toBeTruthy(); + expect(el.querySelector('cd-pool-io-card')).toBeTruthy(); + expect(el.querySelector('cd-table-key-value')).toBeFalsy(); + })); + + it('should render the performance section with Grafana', fakeAsync(() => { + mockActivatedRoute.snapshot.data.section = 'performance'; + fixture.detectChanges(); + paramMapSubject.next(convertToParamMap({ name: 'test-pool' })); + tick(); + fixture.detectChanges(); + + const grafanaDebugEl = fixture.debugElement.query(By.css('cd-grafana')); + expect(grafanaDebugEl).toBeTruthy(); + expect(grafanaDebugEl.properties['grafanaPath']).toBe( + 'ceph-pool-details?var-pool_name=test-pool' + ); + })); + + it('should render the configuration section', fakeAsync(() => { + mockActivatedRoute.snapshot.data.section = 'configuration'; + fixture.detectChanges(); + paramMapSubject.next(convertToParamMap({ name: 'test-pool' })); + tick(); + fixture.detectChanges(); + + const el = fixture.nativeElement; + expect(el.querySelector('cd-rbd-configuration-table')).toBeTruthy(); + + const tables = el.querySelectorAll('cd-table'); + expect(tables.length).toBe(1); + })); + + it('should display an error alert if no poolName is present', fakeAsync(() => { + fixture.detectChanges(); + paramMapSubject.next(convertToParamMap({ name: null })); + tick(); + fixture.detectChanges(); + + const el = fixture.nativeElement; + const alertPanel = el.querySelector('cd-alert-panel'); + expect(alertPanel).toBeTruthy(); + expect(alertPanel.textContent).toContain('No pool name found'); + })); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.ts new file mode 100644 index 000000000000..66fddcfbf977 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-page/pool-resource-page.component.ts @@ -0,0 +1,311 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; + +import _ from 'lodash'; +import { forkJoin, of, Subscription } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { PoolService } from '~/app/shared/api/pool.service'; +import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile.service'; +import { CdHelperClass } from '~/app/shared/classes/cd-helper.class'; +import { OverviewField } from '~/app/shared/components/resource-overview-card/resource-overview-card.component'; +import { ChartPoint } from '~/app/shared/models/area-chart-point'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { RbdConfigurationEntry } from '~/app/shared/models/configuration'; +import { ErasureCodeProfile } from '~/app/shared/models/erasure-code-profile'; +import { FormatterService } from '~/app/shared/services/formatter.service'; +import { + getPoolDataProtection, + mapPoolApplications, + Pool, + PoolType, + transformPgStatus +} from '../pool'; +import { PoolStat, PoolStats } from '../pool-stat'; +import { PoolOverviewModel } from '~/app/shared/models/pool-overview.model'; + +type PoolResourceResult = [Pool, RbdConfigurationEntry[], ErasureCodeProfile[], Pool[]]; +type RatePoint = [number, number] | number; + +@Component({ + selector: 'cd-pool-resource-page', + templateUrl: './pool-resource-page.component.html', + styleUrls: ['./pool-resource-page.component.scss'], + standalone: false +}) +export class PoolResourcePageComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + poolName = ''; + section = ''; + poolOverviewFields: OverviewField[] = []; + cacheTierColumns: Array = []; + poolDetails!: object; + selectedPoolConfiguration: RbdConfigurationEntry[] = []; + cacheTiers: Pool[] = []; + overviewModel: PoolOverviewModel = { + name: '', + type: '', + dataProtection: '', + applications: [] as string[], + pgStatus: '', + crushRuleset: '', + usageTotal: 0, + usageUsed: null as number | null, + usagePercent: '', + usedCapacity: '', + availableCapacity: '', + totalCapacity: '', + quotaLimit: '', + isErasure: false, + typeLabel: '', + replicationSize: '', + minSize: '', + erasureK: '', + erasureM: '', + erasureTotal: '', + erasurePlugin: '', + failureDomain: '', + readThroughput: '', + readOps: '', + readOpsChartData: [] as ChartPoint[], + writeThroughput: '', + writeOps: '', + writeOpsChartData: [] as ChartPoint[] + }; + + omittedPoolAttributes = ['cdExecuting', 'cdIsBinary', 'stats']; + private readonly binaryUnits = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + + constructor( + private route: ActivatedRoute, + private poolService: PoolService, + private erasureCodeProfileService: ErasureCodeProfileService, + private formatter: FormatterService + ) { + this.cacheTierColumns = [ + { prop: 'pool_name', name: $localize`Name`, flexGrow: 3 }, + { prop: 'cache_mode', name: $localize`Cache Mode`, flexGrow: 2 }, + { prop: 'cache_min_evict_age', name: $localize`Min Evict Age`, flexGrow: 2 }, + { prop: 'cache_min_flush_age', name: $localize`Min Flush Age`, flexGrow: 2 }, + { prop: 'target_max_bytes', name: $localize`Target Max Bytes`, flexGrow: 2 }, + { prop: 'target_max_objects', name: $localize`Target Max Objects`, flexGrow: 2 } + ]; + } + + ngOnInit(): void { + this.section = this.route.snapshot.data['section'] ?? ''; + + this.sub.add( + this.route.parent?.paramMap + .pipe( + switchMap((pm: ParamMap) => { + this.poolName = pm.get('name') ?? ''; + + if (!this.poolName) { + this.poolOverviewFields = []; + return of(null); + } + + return forkJoin([ + this.poolService.get(this.poolName, true), + this.poolService.getConfiguration(this.poolName), + this.erasureCodeProfileService.list(), + /* Fetch a list of pools ONLY for cache tiers */ + this.poolService.list([ + 'pool', + 'pool_name', + 'cache_mode', + 'cache_min_evict_age', + 'cache_min_flush_age', + 'target_max_bytes', + 'target_max_objects' + ]) + ]); + }) + ) + .subscribe((result: PoolResourceResult | null) => { + if (!result) return; + + const [poolData, poolConf, ecProfiles, lightweightPoolList] = result; + + this.poolDetails = _.omit(poolData || {}, this.omittedPoolAttributes); + this.selectedPoolConfiguration = poolConf || []; + + // Map the integer IDs from 'tiers' to the matching pools in the lightweight list + const tierIds = Array.isArray(poolData?.tiers) ? poolData.tiers : []; + this.cacheTiers = (lightweightPoolList || []).filter((item: Pool) => + tierIds.includes(item.pool) + ); + + const selectedErasureProfile = this.getErasureProfile(poolData, ecProfiles || []); + this.poolOverviewFields = this.buildOverviewFields(poolData); + this.overviewModel = this.buildOverviewModel(poolData, selectedErasureProfile); + + CdHelperClass.updateChanged(this, { + poolDetails: this.poolDetails, + selectedPoolConfiguration: this.selectedPoolConfiguration, + cacheTiers: this.cacheTiers, + poolOverviewFields: this.poolOverviewFields, + overviewModel: this.overviewModel + }); + }) + ); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private buildOverviewModel(pool?: Pool, erasureProfile?: ErasureCodeProfile): PoolOverviewModel { + const poolData = (pool as Pool) || ({} as Pool); + const stats = (poolData.stats || {}) as PoolStats; + const usageUsed = this.getStatLatest(stats.bytes_used); + const usageAvailable = this.getStatLatest(stats.avail_raw); + const usageTotal = + usageUsed !== null && usageAvailable !== null ? usageUsed + usageAvailable : null; + const readOpsChartData = this.getRateChartData(stats.rd, $localize`Read Ops`); + const writeOpsChartData = this.getRateChartData(stats.wr, $localize`Write Ops`); + const isErasure = poolData.type === PoolType.ERASURE; + const erasureK = _.isNumber(erasureProfile?.k) ? `${erasureProfile?.k}` : ''; + const erasureM = _.isNumber(erasureProfile?.m) ? `${erasureProfile?.m}` : ''; + const erasureTotal = + _.isNumber(erasureProfile?.k) && _.isNumber(erasureProfile?.m) + ? `${(erasureProfile?.k as number) + (erasureProfile?.m as number)}` + : ''; + const typeLabel = isErasure ? $localize`Erasure Coded` : poolData.type || ''; + + return { + name: poolData.pool_name || this.poolName, + type: poolData.type || '', + typeLabel, + dataProtection: getPoolDataProtection(poolData), + applications: mapPoolApplications(poolData.application_metadata || []), + pgStatus: transformPgStatus(poolData.pg_status), + crushRuleset: poolData.crush_rule, + usageTotal: usageTotal ?? 0, + usageUsed, + usagePercent: + usageTotal && usageTotal > 0 && usageUsed !== null + ? `${Math.round((usageUsed / usageTotal) * 1000) / 10}%` + : '', + usedCapacity: this.formatBytes(usageUsed), + availableCapacity: this.formatBytes(usageAvailable), + totalCapacity: this.formatBytes(usageTotal), + quotaLimit: + Number.isFinite(poolData.quota_max_bytes) && poolData.quota_max_bytes > 0 + ? this.formatBytes(poolData.quota_max_bytes) + : '', + isErasure, + replicationSize: Number.isFinite(poolData.size) ? `${poolData.size}` : '', + minSize: Number.isFinite(poolData.min_size) ? `${poolData.min_size}` : '', + erasureK, + erasureM, + erasureTotal, + erasurePlugin: erasureProfile?.plugin || '', + failureDomain: erasureProfile?.['crush-failure-domain'] || '', + readThroughput: this.formatRate(this.getStatRate(stats.rd_bytes), true) || '', + readOps: this.formatRate(this.getStatRate(stats.rd), false) || '', + readOpsChartData, + writeThroughput: this.formatRate(this.getStatRate(stats.wr_bytes), true) || '', + writeOps: this.formatRate(this.getStatRate(stats.wr), false) || '', + writeOpsChartData + }; + } + + private getErasureProfile( + pool: Pool, + profiles: ErasureCodeProfile[] + ): ErasureCodeProfile | undefined { + if (pool?.type !== PoolType.ERASURE || !pool?.erasure_code_profile) { + return undefined; + } + + return profiles.find((profile) => profile.name === pool.erasure_code_profile); + } + + private buildOverviewFields(pool?: Pool): OverviewField[] { + const poolData = (pool as Pool) || ({} as Pool); + + return [ + { + label: $localize`Name`, + value: poolData.pool_name || this.poolName + }, + { + label: $localize`Data Protection`, + value: getPoolDataProtection(poolData) + }, + { + label: $localize`Applications`, + values: mapPoolApplications(poolData.application_metadata || []), + type: 'tags' + }, + { + label: $localize`PG Status`, + value: transformPgStatus(poolData.pg_status) + } + ]; + } + + private getStatLatest(stat?: PoolStat): number | null { + return Number.isFinite(stat?.latest) ? (stat as PoolStat).latest : null; + } + + private getStatRate(stat?: PoolStat): number | null { + return Number.isFinite(stat?.rate) ? (stat as PoolStat).rate : null; + } + + private getRateChartData(stat: PoolStat | undefined, groupLabel: string): ChartPoint[] { + if (!Array.isArray(stat?.rates)) { + return []; + } + + const fallbackStart = Date.now() - Math.max(stat.rates.length - 1, 0) * 60000; + + return stat.rates.reduce((points: ChartPoint[], point: RatePoint, index: number) => { + const fallbackTimestamp = new Date(fallbackStart + index * 60000); + + if (Array.isArray(point)) { + const rawTimestamp = Number(point[0]); + const rawValue = Number(point[1]); + + if (Number.isFinite(rawValue)) { + points.push({ + timestamp: Number.isFinite(rawTimestamp) + ? new Date(rawTimestamp * 1000) + : fallbackTimestamp, + values: { [groupLabel]: rawValue } + }); + } + return points; + } + + const rawValue = Number(point); + + if (Number.isFinite(rawValue)) { + points.push({ + timestamp: fallbackTimestamp, + values: { [groupLabel]: rawValue } + }); + } + + return points; + }, []); + } + + private formatBytes(value?: number): string { + return this.formatter.format_number(value, 1024, this.binaryUnits, 1); + } + + private formatRate(value?: number, binary?: boolean): string { + if (binary) { + return `${this.formatter.format_number(value, 1024, this.binaryUnits, 1)}/s`; + } + + if (value == null || !Number.isFinite(value)) { + return '0/s'; + } + + return `${Math.round(value * 10) / 10}/s`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.html new file mode 100644 index 000000000000..1594605ff175 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.html @@ -0,0 +1,52 @@ + + @if (poolDataProtection || poolApplications.length || getVisiblePoolActions().length) { +
+
+ @if (poolDataProtection) { + {{ poolDataProtection | empty }} + } + @for (app of poolApplications; track app) { + {{ app }} + } +
+ + @if (getVisiblePoolActions().length) { +
+ + @for (action of getVisiblePoolActions(); track action.name) { + + + } + +
+ } +
+ } +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.scss new file mode 100644 index 000000000000..20f939f6ebbc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.scss @@ -0,0 +1,65 @@ +@use '@carbon/layout'; +@use '@carbon/colors'; + +.pool-header-extra { + display: flex; + align-items: center; + justify-content: space-between; + gap: layout.$spacing-05; + flex: 1 1 auto; + min-width: 0; +} + +.pool-header-tags { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: layout.$spacing-02; + min-width: 0; + overflow-x: auto; +} + +.pool-header-actions { + margin-left: auto; + flex-shrink: 0; +} + +.pool-details-layout .sidebar-header-content { + flex-wrap: nowrap; + width: 100%; +} + +.pool-details-layout .sidebar-header-content h2 { + white-space: nowrap; + flex-shrink: 0; +} + +.pool-details-layout .sidebar-header { + padding-right: var(--cds-spacing-07); +} + +/* Menu is rendered under document.body; scope fixes by menu id. */ +#pool-actions-menu.cds--menu { + width: max-content !important; + min-width: 12rem; + background-color: var(--cds-layer-01) !important; +} + +#pool-actions-menu.cds--menu > .cds--menu-item { + grid-template-columns: minmax(0, 1fr) !important; +} + +#pool-actions-menu.cds--menu .cds--menu-item { + color: var(--cds-text-primary); +} + +#pool-actions-menu.cds--menu .cds--menu-item__label { + overflow: visible; + text-overflow: unset; + white-space: normal; +} + +#pool-actions-menu.cds--menu .cds--menu-item:hover, +#pool-actions-menu.cds--menu .cds--menu-item:focus { + background-color: var(--cds-background-hover) !important; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.spec.ts new file mode 100644 index 000000000000..039fd0ec62fc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.spec.ts @@ -0,0 +1,81 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { PoolService } from '~/app/shared/api/pool.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { PoolResourceSidebarComponent } from './pool-resource-sidebar.component'; +import { of } from 'rxjs'; + +describe('PoolResourceSidebarComponent', () => { + let poolResourceSidebarComponent: PoolResourceSidebarComponent; + let fixture: ComponentFixture; + + const fakeAuthStorageService = { + getPermissions: () => ({ grafana: { read: true } }) + }; + + configureTestBed({ + imports: [BrowserAnimationsModule, SharedModule, HttpClientTestingModule, RouterTestingModule], + declarations: [PoolResourceSidebarComponent], + providers: [ + { provide: AuthStorageService, useValue: fakeAuthStorageService }, + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ name: 'micro_pool' })) + } + } + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PoolResourceSidebarComponent); + poolResourceSidebarComponent = fixture.componentInstance; + }); + + it('should create', () => { + expect(poolResourceSidebarComponent).toBeTruthy(); + }); + + describe('Pool resource layout', () => { + beforeEach(() => { + spyOn(TestBed.inject(PoolService), 'get').and.returnValue( + of({ + pool_name: 'micro_pool', + tiers: [ + { + pool_name: 'tier_pool', + cache_mode: 'writeback' + } + ], + cdExecuting: true, + stats: { bytes_used: { latest: 1 } } + }) + ); + spyOn(TestBed.inject(PoolService), 'getConfiguration').and.returnValue(of([])); + fixture.detectChanges(); + }); + + it('should render the sidebar layout', () => { + const layout = fixture.nativeElement.querySelector('cd-sidebar-layout'); + expect(layout).toBeTruthy(); + }); + + it('should build sidebar items', () => { + expect(poolResourceSidebarComponent.sidebarItems.map((item) => item.label)).toEqual([ + 'Overview', + 'Configuration', + 'Performance' + ]); + }); + + it('should set the pool name title', () => { + expect(poolResourceSidebarComponent.poolName).toBe('micro_pool'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.ts new file mode 100644 index 000000000000..ab4e2af6d138 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-resource-sidebar/pool-resource-sidebar.component.ts @@ -0,0 +1,213 @@ +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; + +import { Subscription } from 'rxjs'; + +import _ from 'lodash'; +import { ConfigurationService } from '~/app/shared/api/configuration.service'; +import { PoolService } from '~/app/shared/api/pool.service'; +import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component'; +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { Permissions } from '~/app/shared/models/permissions'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { getPoolDataProtection, mapPoolApplications, Pool } from '../pool'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; + +@Component({ + selector: 'cd-pool-resource-sidebar', + templateUrl: './pool-resource-sidebar.component.html', + styleUrls: ['./pool-resource-sidebar.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class PoolResourceSidebarComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + public readonly basePath = '/pool/view'; + poolName = ''; + poolDataProtection = ''; + poolApplications: string[] = []; + poolActions: CdTableAction[] = []; + poolSelection = new CdTableSelection(); + monAllowPoolDelete = false; + sidebarItems: SidebarItem[] = []; + permissions: Permissions; + + constructor( + private route: ActivatedRoute, + private router: Router, + private authStorageService: AuthStorageService, + private poolService: PoolService, + private configurationService: ConfigurationService, + private actionLabels: ActionLabelsI18n, + private modalService: ModalCdsService, + private taskWrapper: TaskWrapperService + ) { + this.permissions = this.authStorageService.getPermissions(); + } + + ngOnInit(): void { + this.loadDeleteCapability(); + this.sub.add( + this.route.paramMap.subscribe((pm: ParamMap) => { + this.poolName = pm.get('name') ?? ''; + this.buildSidebarItems(this.permissions); + this.loadPoolHeaderMetadata(); + }) + ); + + this.buildPoolActions(); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private buildSidebarItems(permissions: any): void { + const items: SidebarItem[] = [ + { + label: $localize`Overview`, + route: [this.basePath, this.poolName, 'overview'], + routerLinkActiveOptions: { exact: true } + }, + { + label: $localize`Configuration`, + route: [this.basePath, this.poolName, 'configuration'], + routerLinkActiveOptions: { exact: true } + } + ]; + + if (permissions.grafana?.read) { + items.push({ + label: $localize`Performance`, + route: [this.basePath, this.poolName, 'performance'], + routerLinkActiveOptions: { exact: true } + }); + } + + this.sidebarItems = items; + } + + private loadDeleteCapability(): void { + if (this.permissions.configOpt?.read) { + this.sub.add( + this.configurationService.get('mon_allow_pool_delete').subscribe((data: any) => { + if (_.has(data, 'value')) { + const monSection = _.find(data.value, (v) => { + return v.section === 'mon'; + }) || { value: false }; + this.monAllowPoolDelete = monSection.value === 'true'; + } + }) + ); + } else if (this.permissions.pool?.read) { + this.monAllowPoolDelete = true; + } + } + + private loadPoolHeaderMetadata(): void { + if (!this.poolName) { + this.poolDataProtection = ''; + this.poolApplications = []; + this.poolSelection.selected = []; + return; + } + + this.sub.add( + this.poolService.get(this.poolName).subscribe((pool: any) => { + const poolData = pool as Pool; + + this.poolDataProtection = getPoolDataProtection(poolData); + this.poolApplications = mapPoolApplications(poolData?.application_metadata || []); + this.poolSelection.selected = poolData ? [poolData] : []; + this.buildPoolActions(); + }) + ); + } + + private buildPoolActions(): void { + this.poolActions = [ + { + name: this.actionLabels.EDIT, + permission: 'update', + icon: Icons.edit, + click: () => this.editPool(), + disable: (selection: CdTableSelection) => !selection?.hasSingleSelection + }, + { + name: this.actionLabels.DELETE, + permission: 'delete', + icon: Icons.destroy, + click: () => this.deletePool(), + disable: (selection: CdTableSelection) => this.getDeleteDisable(selection) + } + ]; + } + + getVisiblePoolActions(): CdTableAction[] { + return this.poolActions.filter((action) => { + if (action.permission && !this.permissions.pool?.[action.permission]) { + return false; + } + + return !action.visible || action.visible(this.poolSelection); + }); + } + + isPoolActionDisabled(action: CdTableAction): boolean { + return !!action.disable?.(this.poolSelection); + } + + runPoolAction(action: CdTableAction): void { + if (this.isPoolActionDisabled(action)) { + return; + } + + action.click?.(); + } + + private editPool(): void { + if (!this.poolName) { + return; + } + + this.router.navigate(['/pool', URLVerbs.EDIT, this.poolName]); + } + + private deletePool(): void { + const selectedPool = this.poolSelection.first(); + const name = selectedPool?.pool_name || this.poolName; + if (!name) { + return; + } + + this.modalService.show(DeleteConfirmationModalComponent, { + impact: DeletionImpact.high, + itemDescription: 'Pool', + itemNames: [name], + submitActionObservable: () => + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask(`pool/${URLVerbs.DELETE}`, { pool_name: name }), + call: this.poolService.delete(name) + }) + }); + } + + private getDeleteDisable(selection: CdTableSelection): boolean | string { + if (!selection?.hasSingleSelection) { + return true; + } + + if (!this.monAllowPoolDelete) { + return $localize`Pool deletion is disabled by the mon_allow_pool_delete configuration setting.`; + } + + return false; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts index 5db0b9b896af..8186f64c3d85 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts @@ -6,14 +6,19 @@ import { RouterModule, Routes } from '@angular/router'; import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ActionLabels, URLVerbs } from '~/app/shared/constants/app.constants'; +import { AreaChartComponent } from '~/app/shared/components/area-chart/area-chart.component'; import { SharedModule } from '~/app/shared/shared.module'; import { BlockModule } from '../block/block.module'; import { CephSharedModule } from '../shared/ceph-shared.module'; import { CrushRuleFormModalComponent } from './crush-rule-form-modal/crush-rule-form-modal.component'; import { ErasureCodeProfileFormModalComponent } from './erasure-code-profile-form/erasure-code-profile-form-modal.component'; -import { PoolDetailsComponent } from './pool-details/pool-details.component'; +import { PoolResourceSidebarComponent } from './pool-resource-sidebar/pool-resource-sidebar.component'; +import { PoolResourceBreadcrumbResolver } from './pool-resource-page/pool-resource-breadcrumb.resolver'; +import { PoolResourcePageComponent } from './pool-resource-page/pool-resource-page.component'; import { PoolFormComponent } from './pool-form/pool-form.component'; import { PoolListComponent } from './pool-list/pool-list.component'; +import { PoolCapacityProtectionCardComponent } from './pool-capacity-protection-card/pool-capacity-protection-card.component'; +import { PoolIoCardComponent } from './pool-io-card/pool-io-card.component'; import { IconModule, InputModule, @@ -33,7 +38,8 @@ import { ModalModule, ButtonModule, GridModule, - DropdownModule + MenuButtonModule, + ContextMenuModule } from 'carbon-components-angular'; import HelpIcon from '@carbon/icons/es/help/16'; import UnlockedIcon from '@carbon/icons/es/unlocked/16'; @@ -77,7 +83,9 @@ import UserAccessLocked from '@carbon/icons/es/user--access-locked/16'; ModalModule, ButtonModule, GridModule, - DropdownModule + MenuButtonModule, + ContextMenuModule, + AreaChartComponent ], exports: [PoolListComponent, PoolFormComponent], declarations: [ @@ -85,7 +93,10 @@ import UserAccessLocked from '@carbon/icons/es/user--access-locked/16'; PoolFormComponent, ErasureCodeProfileFormModalComponent, CrushRuleFormModalComponent, - PoolDetailsComponent + PoolResourceSidebarComponent, + PoolResourcePageComponent, + PoolCapacityProtectionCardComponent, + PoolIoCardComponent ] }) export class PoolModule { @@ -113,6 +124,29 @@ export class PoolModule { const routes: Routes = [ { path: '', component: PoolListComponent }, + { + path: 'view/:name', + component: PoolResourceSidebarComponent, + data: { breadcrumbs: PoolResourceBreadcrumbResolver }, + children: [ + { path: '', redirectTo: 'overview', pathMatch: 'full' }, + { + path: 'overview', + component: PoolResourcePageComponent, + data: { breadcrumbs: 'Overview', section: 'overview' } + }, + { + path: 'performance', + component: PoolResourcePageComponent, + data: { breadcrumbs: 'Performance', section: 'performance' } + }, + { + path: 'configuration', + component: PoolResourcePageComponent, + data: { breadcrumbs: 'Configuration', section: 'configuration' } + } + ] + }, { path: URLVerbs.CREATE, component: PoolFormComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts index d36f920407e6..6ac3de65e93e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts @@ -6,6 +6,15 @@ export enum PoolType { REPLICATED = 'replicated' } +export const POOL_APPLICATION_LABELS: Record = { + cephfs: $localize`File system`, + rbd: $localize`Block`, + rgw: $localize`Object` +}; + +export const mapPoolApplications = (applications: string[] = []): string[] => + applications.map((application: string) => POOL_APPLICATION_LABELS[application] || application); + export class Pool { cache_target_full_ratio_micro: number; fast_read: boolean; @@ -76,3 +85,27 @@ export class Pool { this.pool_name = name; } } + +export const getPoolDataProtection = (pool?: Pool): string => { + if (pool?.type === PoolType.ERASURE && pool.erasure_code_profile) { + return `EC: ${pool.erasure_code_profile}`; + } + + if (pool?.type === PoolType.REPLICATED && pool.size != null) { + return `replica: x${pool.size}`; + } + + return ''; +}; + +export const transformPgStatus = (pgStatus: any): string => { + if (!pgStatus || typeof pgStatus === 'string') { + return pgStatus || ''; + } + + return ( + Object.entries(pgStatus) + .map(([state, count]) => `${count} ${state}`) + .join(', ') || '' + ); +}; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts index 29b6898c0db7..fc9753cb2bf5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts @@ -56,8 +56,9 @@ export class PoolService { return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' }); } - get(poolName: string) { - return this.http.get(`${this.apiPath}/${poolName}`); + get(poolName: string, stats: boolean = false) { + const url = stats ? `${this.apiPath}/${poolName}?stats=true` : `${this.apiPath}/${poolName}`; + return this.http.get(url); } getList() { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-overview.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-overview.model.ts new file mode 100644 index 000000000000..3678d150944b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-overview.model.ts @@ -0,0 +1,32 @@ +import { ChartPoint } from '~/app/shared/models/area-chart-point'; + +export interface PoolOverviewModel { + name: string; + type: string; + dataProtection: string; + applications: string[]; + pgStatus: string; + crushRuleset: string | number; + usageTotal: number; + usageUsed: number | null; + usagePercent: string; + usedCapacity: string; + availableCapacity: string; + totalCapacity: string; + quotaLimit: string; + isErasure: boolean; + typeLabel: string; + replicationSize: string; + minSize: string; + erasureK: string; + erasureM: string; + erasureTotal: string; + erasurePlugin: string; + failureDomain: string; + readThroughput: string; + readOps: string; + readOpsChartData: ChartPoint[]; + writeThroughput: string; + writeOps: string; + writeOpsChartData: ChartPoint[]; +} -- 2.47.3