From: Syed Ali Ul Hasan Date: Tue, 21 Jul 2026 22:43:44 +0000 (+0530) Subject: mgr/dashboard: migrated images table tabs to resource pages X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=d2e64ba6b4fadc304445810448b6fe5a8e98d21e;p=ceph.git mgr/dashboard: migrated images table tabs to resource pages - Fixes: https://tracker.ceph.com/issues/78479 Signed-off-by: Syed Ali Ul Hasan --- diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/block/images.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/block/images.po.ts index 704f922fb54..ab072a8a07b 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/block/images.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/block/images.po.ts @@ -36,9 +36,7 @@ export class ImagesPageHelper extends PageHelper { cy.get('#size').clear().type(newSize); // click the size box and send new size cy.get('[data-testid=submitBtn]').click(); - - this.getExpandCollapseElement(newName).click(); - cy.get('[data-testid=rbd-details-table]').contains('td', newSize); + this.getTableCell(4, newSize, true).should('exist'); } // Selects RBD image and moves it to the trash, @@ -47,8 +45,6 @@ export class ImagesPageHelper extends PageHelper { // wait for image to be created cy.get('table[cdstable] tbody').first().should('not.contain.text', '(Creating...)'); - this.getFirstTableCell(name).click(); - // click on the drop down and selects the move to trash option cy.get('[data-testid="table-action-btn"]').click({ multiple: true }); cy.get('button.move-to-trash').click({ force: true }); @@ -57,17 +53,16 @@ export class ImagesPageHelper extends PageHelper { // Clicks trash tab cy.contains('.nav-link', 'Trash').click(); - this.getFirstTableCell(name).should('exist'); + this.getTableCell(2, name).should('exist'); } // Checks trash tab table for image and then restores it to the RBD Images table // (could change name if new name is given) - restoreImage(name: string, newName?: string) { + restoreImage(_name: string, newName?: string) { // clicks on trash tab cy.contains('.nav-link', 'Trash').click(); // wait for table to load - this.getFirstTableCell(name).click(); cy.get('[data-testid="table-action-btn"]').click({ multiple: true }); cy.get('button.restore').click({ force: true }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index 2291a091997..85048dc8d7e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -25,8 +25,10 @@ import { OverviewComponent as RbdMirroringComponent } from './mirroring/overview import { PoolEditModeModalComponent } from './mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component'; import { RbdConfigurationFormComponent } from './rbd-configuration-form/rbd-configuration-form.component'; import { RbdConfigurationListComponent } from './rbd-configuration-list/rbd-configuration-list.component'; -import { RbdDetailsComponent } from './rbd-details/rbd-details.component'; import { RbdFormComponent } from './rbd-form/rbd-form.component'; +import { RbdImageResourceBreadcrumbResolver } from './rbd-image-resource-page/rbd-image-resource-breadcrumb.resolver'; +import { RbdImageResourcePageComponent } from './rbd-image-resource-page/rbd-image-resource-page.component'; +import { RbdImageResourceSidebarComponent } from './rbd-image-resource-sidebar/rbd-image-resource-sidebar.component'; import { RbdListComponent } from './rbd-list/rbd-list.component'; import { RbdNamespaceFormModalComponent } from './rbd-namespace-form/rbd-namespace-form-modal.component'; import { RbdNamespaceListComponent } from './rbd-namespace-list/rbd-namespace-list.component'; @@ -158,8 +160,9 @@ import { NvmeofEditAuthenticationComponent } from './nvmeof-edit-authentication/ IscsiSettingComponent, IscsiTabsComponent, IscsiTargetListComponent, - RbdDetailsComponent, RbdFormComponent, + RbdImageResourceSidebarComponent, + RbdImageResourcePageComponent, RbdNamespaceFormModalComponent, RbdNamespaceListComponent, RbdSnapshotListComponent, @@ -288,6 +291,34 @@ const routes: Routes = [ path: `${URLVerbs.COPY}/:image_spec/:snap`, component: RbdFormComponent, data: { breadcrumbs: ActionLabels.COPY } + }, + { + path: ':image_spec', + component: RbdImageResourceSidebarComponent, + data: { breadcrumbs: RbdImageResourceBreadcrumbResolver }, + children: [ + { path: '', redirectTo: 'overview', pathMatch: 'full' }, + { + path: 'overview', + component: RbdImageResourcePageComponent, + data: { breadcrumbs: 'Overview', section: 'overview' } + }, + { + path: 'snapshots', + component: RbdImageResourcePageComponent, + data: { breadcrumbs: 'Snapshots', section: 'snapshots' } + }, + { + path: 'configuration', + component: RbdImageResourcePageComponent, + data: { breadcrumbs: 'Configuration', section: 'configuration' } + }, + { + path: 'performance', + component: RbdImageResourcePageComponent, + data: { breadcrumbs: 'Performance', section: 'performance' } + } + ] } ] }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html index e98296267c7..4ace0016754 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html @@ -13,11 +13,15 @@
Global Image diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html deleted file mode 100644 index 10c1d1a7985..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html +++ /dev/null @@ -1,296 +0,0 @@ - - Only available for RBD images with fast-diff enabled - - - - - -
-
- - Information can not be displayed for RBD in status 'Removing'. - - - - - Image - - - Global - - diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts deleted file mode 100644 index 75797654623..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; - -import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; - -import { SharedModule } from '~/app/shared/shared.module'; -import { configureTestBed } from '~/testing/unit-test-helper'; -import { RbdConfigurationListComponent } from '../rbd-configuration-list/rbd-configuration-list.component'; -import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component'; -import { RbdDetailsComponent } from './rbd-details.component'; - -describe('RbdDetailsComponent', () => { - let component: RbdDetailsComponent; - let fixture: ComponentFixture; - - configureTestBed({ - declarations: [RbdDetailsComponent, RbdSnapshotListComponent, RbdConfigurationListComponent], - imports: [SharedModule, NgbTooltipModule, RouterTestingModule, NgbNavModule] - }); - - beforeEach(() => { - fixture = TestBed.createComponent(RbdDetailsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts deleted file mode 100644 index 66982ba1c25..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Component, Input, OnChanges, TemplateRef, ViewChild } from '@angular/core'; - -import { NgbNav } from '@ng-bootstrap/ng-bootstrap'; - -import { RbdFormModel } from '../rbd-form/rbd-form.model'; - -@Component({ - selector: 'cd-rbd-details', - templateUrl: './rbd-details.component.html', - styleUrls: ['./rbd-details.component.scss'], - standalone: false -}) -export class RbdDetailsComponent implements OnChanges { - @Input() - selection: RbdFormModel; - @Input() - images: any; - - @ViewChild('poolConfigurationSourceTpl', { static: true }) - poolConfigurationSourceTpl: TemplateRef; - - @ViewChild(NgbNav, { static: true }) - nav: NgbNav; - - rbdDashboardUrl: string; - - ngOnChanges() { - if (this.selection) { - this.rbdDashboardUrl = `ceph-block-details?var-pool=${this.selection['pool_name']}&var-image=${this.selection['name']}`; - } - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-breadcrumb.resolver.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-breadcrumb.resolver.spec.ts new file mode 100644 index 00000000000..629341acd5c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-breadcrumb.resolver.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +import { RbdImageResourceBreadcrumbResolver } from './rbd-image-resource-breadcrumb.resolver'; +import { ImageSpec } from '~/app/shared/models/image-spec'; + +describe('RbdImageResourceBreadcrumbResolver', () => { + let resolver: RbdImageResourceBreadcrumbResolver; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [RbdImageResourceBreadcrumbResolver] + }); + + resolver = TestBed.inject(RbdImageResourceBreadcrumbResolver); + jest.spyOn(resolver as any, 'getFullPath').mockReturnValue('/mock/full/path'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should be created', () => { + expect(resolver).toBeTruthy(); + }); + + it('should resolve breadcrumb with parsed image name from URL decoded spec', () => { + const mockRoute = { + paramMap: { + get: jest.fn().mockReturnValue('test-pool%2Ftest-image') + } + } as unknown as ActivatedRouteSnapshot; + + jest.spyOn(ImageSpec, 'fromString').mockReturnValue({ + imageName: 'test-image' + } as any); + const result = resolver.resolve(mockRoute); + expect(mockRoute.paramMap.get).toHaveBeenCalledWith('image_spec'); + expect(ImageSpec.fromString).toHaveBeenCalledWith('test-pool/test-image'); + expect(result).toEqual([{ text: 'test-image', path: '/mock/full/path' }]); + }); + + it('should fallback to the raw route string if ImageSpec parsing fails', () => { + const invalidRouteParam = 'malformed-spec-string'; + const mockRoute = { + paramMap: { + get: jest.fn().mockReturnValue(invalidRouteParam) + } + } as unknown as ActivatedRouteSnapshot; + + jest.spyOn(ImageSpec, 'fromString').mockImplementation(() => { + throw new Error('Invalid format'); + }); + + const result = resolver.resolve(mockRoute); + expect(result).toEqual([{ text: invalidRouteParam, path: '/mock/full/path' }]); + }); + + it('should return an empty text string if the route parameter is missing', () => { + const mockRoute = { + paramMap: { + get: jest.fn().mockReturnValue(null) + } + } as unknown as ActivatedRouteSnapshot; + + jest.spyOn(ImageSpec, 'fromString'); + + const result = resolver.resolve(mockRoute); + expect(ImageSpec.fromString).not.toHaveBeenCalled(); + expect(result).toEqual([{ text: '', path: '/mock/full/path' }]); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-breadcrumb.resolver.ts new file mode 100644 index 00000000000..0ff7ec83861 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-breadcrumb.resolver.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs'; +import { ImageSpec } from '~/app/shared/models/image-spec'; + +@Injectable({ + providedIn: 'root' +}) +export class RbdImageResourceBreadcrumbResolver extends BreadcrumbsResolver { + resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] { + const imageName = this.getImageNameFromRoute(route.paramMap.get('image_spec') || ''); + + return [{ text: imageName, path: this.getFullPath(route) }]; + } + + private getImageNameFromRoute(imageSpecRoute: string): string { + if (!imageSpecRoute) { + return ''; + } + + try { + const imageSpec = ImageSpec.fromString(decodeURIComponent(imageSpecRoute)); + return imageSpec.imageName; + } catch { + return imageSpecRoute; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.html new file mode 100644 index 00000000000..42c2bfbabd3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.html @@ -0,0 +1,50 @@ +@if (selection && selection.source !== 'REMOVING') { + @switch (section) { + @case ('overview') { + + + } + @case ('snapshots') { + + } + @case ('configuration') { + + } + @case ('performance') { + + + } + } +} @else if (selection && selection.source === 'REMOVING') { + Information can not be displayed for RBD in status 'Removing'. +} @else if (notFound) { + No RBD image found. +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.spec.ts new file mode 100644 index 00000000000..a601d3d168b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.spec.ts @@ -0,0 +1,267 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +import { RbdImageResourcePageComponent } from './rbd-image-resource-page.component'; +import { RbdImageResourceStateService } from '../../../shared/services/rbd-image-resource-state.service'; +import { RbdConfigurationService } from '~/app/shared/services/rbd-configuration.service'; +import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe'; +import { RbdFormModel } from '../rbd-form/rbd-form.model'; + +describe('RbdImageResourcePageComponent', () => { + let component: RbdImageResourcePageComponent; + let fixture: ComponentFixture; + let rbdConfigurationServiceSpy: jest.Mocked; + let imageSubject: BehaviorSubject; + + const baseMockImage: any = { + name: 'test-image', + pool_name: 'test-pool', + namespace: 'test-namespace', + data_pool: 'test-data-pool', + timestamp: '2024-01-01T00:00:00Z', + size: 1024, + num_objs: 10, + obj_size: 102, + features_name: ['fast-diff', 'exclusive-lock'], + disk_usage: 512, + total_disk_usage: 1024, + stripe_unit: 128, + stripe_count: 1, + parent: { + pool_name: 'parent-pool', + pool_namespace: 'parent-ns', + image_name: 'parent-image', + snap_name: 'parent-snap' + }, + block_name_prefix: 'rbd_data.123', + order: 22, + image_format: 2, + mirror_mode: ['image', 'journal'], + primary: true, + configuration: [{ name: 'rbd_cache', source: 0, value: 'true' }] + }; + + class MockDimlessBinaryPipe { + transform(value: any): string { + return `${value} B`; + } + } + + class MockDimlessPipe { + transform(value: any): string { + return `${value} items`; + } + } + + class MockCdDatePipe { + transform(value: any): string { + return `Date: ${value}`; + } + } + + beforeEach(async () => { + imageSubject = new BehaviorSubject(null); + + // Create Jest mocks for services + const rbdImageResourceStateServiceMock = { + image$: imageSubject.asObservable() + }; + + rbdConfigurationServiceSpy = { + getOptionByName: jest.fn().mockReturnValue({ displayName: 'RBD Cache', type: 'boolean' }) + } as any; + + const activatedRouteMock = { + snapshot: { data: { section: 'overview' } } + }; + + await TestBed.configureTestingModule({ + declarations: [RbdImageResourcePageComponent], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: RbdImageResourceStateService, useValue: rbdImageResourceStateServiceMock }, + { provide: RbdConfigurationService, useValue: rbdConfigurationServiceSpy }, + { provide: DimlessBinaryPipe, useClass: MockDimlessBinaryPipe }, + { provide: DimlessPipe, useClass: MockDimlessPipe }, + { provide: CdDatePipe, useClass: MockCdDatePipe } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RbdImageResourcePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); // Triggers ngOnInit + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize section from route data', () => { + expect(component.section).toBe('overview'); + }); + + it('should handle null image gracefully', () => { + imageSubject.next(null); + fixture.detectChanges(); + + expect(component.notFound).toBe(true); + expect(component.selection).toBeUndefined(); + expect(component.overviewFields).toEqual([]); + expect(component.rbdDashboardUrl).toBe(''); + }); + + it('should process a valid image and enrich configuration', () => { + imageSubject.next({ ...baseMockImage } as RbdFormModel); + fixture.detectChanges(); + + expect(component.notFound).toBe(false); + expect(component.selection).toBeDefined(); + expect(component.rbdDashboardUrl).toBe('rbd-details?var-pool=test-pool&var-image=test-image'); + + // Verify configuration was enriched + expect(rbdConfigurationServiceSpy.getOptionByName).toHaveBeenCalledWith('rbd_cache'); + expect(component.selection.configuration?.[0]).toEqual( + expect.objectContaining({ name: 'rbd_cache', displayName: 'RBD Cache' }) + ); + }); + + describe('Overview Fields Generation', () => { + beforeEach(() => { + imageSubject.next({ ...baseMockImage } as RbdFormModel); + fixture.detectChanges(); + }); + + it('should map base properties and pipes correctly', () => { + const getField = (label: string) => + component.overviewFields.find((f) => f.label === label)?.value; + + expect(getField('Name')).toBe('test-image'); + expect(getField('Size')).toBe('1024 B'); + expect(getField('Objects')).toBe('10 items'); + expect(getField('Created')).toBe('Date: 2024-01-01T00:00:00Z'); + expect(getField('Order')).toBe(22); + }); + + it('should calculate disk usage percentage when fast-diff is present', () => { + // 512 / 1024 = 50% + const usageField = component.overviewFields.find((f) => f.label === 'Usage'); + expect(usageField?.value).toBe('50.00%'); + }); + }); + + describe('Edge cases and helper methods', () => { + let testImage: any; + + beforeEach(() => { + // Create a fresh clone of the base mock for each test to modify + testImage = JSON.parse(JSON.stringify(baseMockImage)); + }); + + it('should return N/A for usage if fast-diff is missing', () => { + testImage.features_name = ['exclusive-lock']; + imageSubject.next(testImage as RbdFormModel); + + const usageField = component.overviewFields.find((f) => f.label === 'Usage'); + expect(usageField?.value).toBe('N/A'); + }); + + it('should return 0% for usage if size is 0 or falsy', () => { + testImage.size = 0; + imageSubject.next(testImage as RbdFormModel); + + const usageField = component.overviewFields.find((f) => f.label === 'Usage'); + expect(usageField?.value).toBe('0%'); + }); + + it('should return N/A for provisioned if fast-diff is missing', () => { + testImage.features_name = []; + imageSubject.next(testImage as RbdFormModel); + + const provField = component.overviewFields.find((f) => f.label === 'Provisioned'); + expect(provField?.value).toBe('N/A'); + }); + + it('should calculate provisioned values when fast-diff is present', () => { + imageSubject.next(testImage as RbdFormModel); + + const provField = component.overviewFields.find((f) => f.label === 'Provisioned'); + const totalProvField = component.overviewFields.find((f) => f.label === 'Total provisioned'); + + expect(provField?.value).toBe('512 B'); + expect(totalProvField?.value).toBe('1024 B'); + }); + + it('should format parent string with and without namespace', () => { + // With namespace (from base mock) + imageSubject.next(testImage as RbdFormModel); + expect(component.overviewFields.find((f) => f.label === 'Parent')?.value).toBe( + 'parent-pool/parent-ns/parent-image@parent-snap' + ); + + // Without namespace + delete testImage.parent.pool_namespace; + imageSubject.next(testImage as RbdFormModel); + expect(component.overviewFields.find((f) => f.label === 'Parent')?.value).toBe( + 'parent-pool/parent-image@parent-snap' + ); + + // Null parent + testImage.parent = null; + imageSubject.next(testImage as RbdFormModel); + expect(component.overviewFields.find((f) => f.label === 'Parent')?.value).toBeUndefined(); + }); + + it('should format mirroring string properly based on array/string and primary flag', () => { + // Array mode + primary + imageSubject.next(testImage as RbdFormModel); + expect(component.overviewFields.find((f) => f.label === 'Mirroring')?.value).toBe( + 'image / journal / primary' + ); + + // String mode + secondary + testImage.mirror_mode = 'pool'; + testImage.primary = false; + imageSubject.next(testImage as RbdFormModel); + expect(component.overviewFields.find((f) => f.label === 'Mirroring')?.value).toBe( + 'pool / secondary' + ); + }); + + it('should determine next scheduled snapshot properly', () => { + // From mirror_mode array + testImage.mirror_mode = ['image', 'journal', '2024-12-31T00:00:00Z']; + imageSubject.next(testImage as RbdFormModel); + expect( + component.overviewFields.find((f) => f.label === 'Next Scheduled Snapshot')?.value + ).toBe('Date: 2024-12-31T00:00:00Z'); + + // From schedule_info + testImage.mirror_mode = 'pool'; + testImage.schedule_info = { schedule_time: '2025-01-01T00:00:00Z' }; + imageSubject.next(testImage as RbdFormModel); + expect( + component.overviewFields.find((f) => f.label === 'Next Scheduled Snapshot')?.value + ).toBe('Date: 2025-01-01T00:00:00Z'); + + // Fallback + delete testImage.schedule_info; + imageSubject.next(testImage as RbdFormModel); + expect( + component.overviewFields.find((f) => f.label === 'Next Scheduled Snapshot')?.value + ).toBeUndefined(); + }); + }); + + it('should unsubscribe on destroy', () => { + const subSpy = jest.spyOn(component['sub'], 'unsubscribe'); + component.ngOnDestroy(); + expect(subSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.ts new file mode 100644 index 00000000000..c74f9d935cd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.ts @@ -0,0 +1,156 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { OverviewField } from '~/app/shared/components/resource-overview-card/resource-overview-card.component'; +import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe'; +import { RbdConfigurationService } from '~/app/shared/services/rbd-configuration.service'; +import { RbdFormModel } from '../rbd-form/rbd-form.model'; +import { RbdConfigurationEntry } from '~/app/shared/models/configuration'; +import { RbdImageResourceStateService } from '../../../shared/services/rbd-image-resource-state.service'; + +@Component({ + selector: 'cd-rbd-image-resource-page', + templateUrl: './rbd-image-resource-page.component.html', + styleUrls: ['./rbd-image-resource-page.component.scss'], + standalone: false +}) +export class RbdImageResourcePageComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + + section = ''; + selection: RbdFormModel; + notFound = false; + overviewFields: OverviewField[] = []; + rbdDashboardUrl = ''; + + constructor( + private route: ActivatedRoute, + private rbdImageResourceStateService: RbdImageResourceStateService, + private rbdConfigurationService: RbdConfigurationService, + private dimlessBinary: DimlessBinaryPipe, + private dimless: DimlessPipe, + private cdDate: CdDatePipe + ) {} + + ngOnInit(): void { + this.section = this.route.snapshot.data['section'] ?? 'overview'; + + this.sub.add( + this.rbdImageResourceStateService.image$.subscribe((image: RbdFormModel | null) => { + this.applyImage(image); + }) + ); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private applyImage(image: RbdFormModel): void { + this.notFound = !image; + if (!image) { + this.selection = undefined; + this.overviewFields = []; + this.rbdDashboardUrl = ''; + return; + } + + this.selection = image; + this.enrichConfiguration(this.selection); + this.overviewFields = this.buildOverviewFields(image); + this.rbdDashboardUrl = `rbd-details?var-pool=${image.pool_name}&var-image=${image.name}`; + } + + private enrichConfiguration(image: RbdFormModel): void { + if (!image?.configuration?.length) { + return; + } + + image.configuration = image.configuration.map((option: RbdConfigurationEntry) => + Object.assign(option, this.rbdConfigurationService.getOptionByName(option.name)) + ); + } + + private buildOverviewFields(image: RbdFormModel): OverviewField[] { + return [ + { label: $localize`Name`, value: image.name }, + { label: $localize`Pool`, value: image.pool_name }, + { label: $localize`Namespace`, value: image.namespace }, + { label: $localize`Data Pool`, value: image.data_pool }, + { label: $localize`Created`, value: this.cdDate.transform(image['timestamp']) }, + { label: $localize`Size`, value: this.dimlessBinary.transform(image.size) }, + { label: $localize`Usage`, value: this.getUsageValue(image) }, + { label: $localize`Objects`, value: this.dimless.transform(image['num_objs']) }, + { label: $localize`Object size`, value: this.dimlessBinary.transform(image.obj_size) }, + { + label: $localize`Mirroring`, + value: this.getMirroringValue(image) + }, + { + label: $localize`Next Scheduled Snapshot`, + value: this.getNextScheduledSnapshot(image) + }, + { + label: $localize`Features`, + values: image['features_name'], + type: 'tags' + }, + { + label: $localize`Provisioned`, + value: this.getProvisionedValue(image['disk_usage'], image['features_name']) + }, + { + label: $localize`Total provisioned`, + value: this.getProvisionedValue(image['total_disk_usage'], image['features_name']) + }, + { label: $localize`Striping unit`, value: this.dimlessBinary.transform(image.stripe_unit) }, + { label: $localize`Striping count`, value: image.stripe_count }, + { + label: $localize`Parent`, + value: image['parent'] + ? `${image['parent'].pool_name}${image['parent'].pool_namespace ? `/${image['parent'].pool_namespace}` : ''}/${image['parent'].image_name}@${image['parent'].snap_name}` + : undefined + }, + { label: $localize`Block name prefix`, value: image['block_name_prefix'] }, + { label: $localize`Order`, value: image['order'] }, + { label: $localize`Format Version`, value: image['image_format'] } + ]; + } + + private getProvisionedValue(value: number, features: string[] = []): string { + return features.indexOf('fast-diff') === -1 ? 'N/A' : this.dimlessBinary.transform(value); + } + + private getUsageValue(image: RbdFormModel): string { + if (!image['features_name']?.includes('fast-diff')) return 'N/A'; + return image.size ? `${((image['disk_usage'] / image.size) * 100).toFixed(2)}%` : '0%'; + } + + private getMirroringValue(image: RbdFormModel): string { + const values = []; + if (Array.isArray(image.mirror_mode)) { + values.push(image.mirror_mode[0]); + values.push(image.mirror_mode[1]); + } else { + values.push(image.mirror_mode); + } + + if (image['primary'] === true) { + values.push($localize`primary`); + } else if (image['primary'] === false) { + values.push($localize`secondary`); + } + + return values.filter(Boolean).join(' / '); + } + + private getNextScheduledSnapshot(image: RbdFormModel): string | undefined { + const time = + (Array.isArray(image.mirror_mode) && image.mirror_mode[2]) || + image.schedule_info?.schedule_time; + return time ? this.cdDate.transform(time) : undefined; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.html new file mode 100644 index 00000000000..030ad818cfe --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.spec.ts new file mode 100644 index 00000000000..2486a679627 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.spec.ts @@ -0,0 +1,117 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Subject } from 'rxjs'; + +import { RbdImageResourceSidebarComponent } from './rbd-image-resource-sidebar.component'; +import { RbdImageResourceStateService } from '../../../shared/services/rbd-image-resource-state.service'; +import { ImageSpec } from '~/app/shared/models/image-spec'; + +describe('RbdImageResourceSidebarComponent', () => { + let component: RbdImageResourceSidebarComponent; + let fixture: ComponentFixture; + let stateServiceMock: any; + let paramMapSubject: Subject; + + beforeEach(async () => { + paramMapSubject = new Subject(); + const activatedRouteMock = { + paramMap: paramMapSubject.asObservable() + }; + + stateServiceMock = { + load: jest.fn() + }; + + await TestBed.configureTestingModule({ + declarations: [RbdImageResourceSidebarComponent], + providers: [{ provide: ActivatedRoute, useValue: activatedRouteMock }], + schemas: [NO_ERRORS_SCHEMA] + }) + // Crucial: Override the component's internal provider to use our mock + .overrideComponent(RbdImageResourceSidebarComponent, { + set: { + providers: [{ provide: RbdImageResourceStateService, useValue: stateServiceMock }] + } + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RbdImageResourceSidebarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit routing and state initialization', () => { + it('should handle empty route param gracefully', () => { + paramMapSubject.next({ get: () => null }); + + expect(component.imageSpecRoute).toBe(''); + expect(component.imageName).toBe(''); + expect(component.sidebarItems.length).toBe(4); + expect(stateServiceMock.load).toHaveBeenCalledWith(''); + }); + + it('should build sidebar items correctly based on route param', () => { + const testRoute = 'test-pool%2Ftest-image'; + paramMapSubject.next({ get: () => testRoute }); + + expect(component.imageSpecRoute).toBe(testRoute); + expect(component.sidebarItems.length).toBe(4); + + // Verify the generated routes + expect(component.sidebarItems[0].route).toEqual(['/block/rbd', testRoute, 'overview']); + expect(component.sidebarItems[1].route).toEqual(['/block/rbd', testRoute, 'snapshots']); + expect(component.sidebarItems[2].route).toEqual(['/block/rbd', testRoute, 'configuration']); + expect(component.sidebarItems[3].route).toEqual(['/block/rbd', testRoute, 'performance']); + + // Verify the state service was told to load the route + expect(stateServiceMock.load).toHaveBeenCalledWith(testRoute); + }); + }); + + describe('Fallback Image Name Generation', () => { + it('should parse a valid image spec string', () => { + const testRoute = 'test-pool%2Ftest-image'; + + // Mock ImageSpec.fromString to return a successful parsed object + jest.spyOn(ImageSpec, 'fromString').mockReturnValue({ + imageName: 'test-image' + } as any); + + paramMapSubject.next({ get: () => testRoute }); + + expect(ImageSpec.fromString).toHaveBeenCalledWith('test-pool/test-image'); // URL decoded + expect(component.imageName).toBe('test-image'); + }); + + it('should fallback to the raw route string if parsing fails', () => { + const invalidRoute = 'malformed-spec-string'; + + // Force ImageSpec.fromString to throw an error simulating a parsing failure + jest.spyOn(ImageSpec, 'fromString').mockImplementation(() => { + throw new Error('Invalid format'); + }); + + paramMapSubject.next({ get: () => invalidRoute }); + + // Component should catch the error and fallback to the raw string + expect(component.imageName).toBe(invalidRoute); + }); + }); + + it('should unsubscribe on destroy', () => { + const subSpy = jest.spyOn(component['sub'], 'unsubscribe'); + component.ngOnDestroy(); + expect(subSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.ts new file mode 100644 index 00000000000..d5308c8c662 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.ts @@ -0,0 +1,81 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component'; +import { ImageSpec } from '~/app/shared/models/image-spec'; +import { RbdImageResourceStateService } from '../../../shared/services/rbd-image-resource-state.service'; + +@Component({ + selector: 'cd-rbd-image-resource-sidebar', + templateUrl: './rbd-image-resource-sidebar.component.html', + styleUrls: ['./rbd-image-resource-sidebar.component.scss'], + providers: [RbdImageResourceStateService], + standalone: false +}) +export class RbdImageResourceSidebarComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + readonly basePath = '/block/rbd'; + imageSpecRoute = ''; + imageName = ''; + sidebarItems: SidebarItem[] = []; + + constructor( + private route: ActivatedRoute, + private rbdImageResourceStateService: RbdImageResourceStateService + ) {} + + ngOnInit(): void { + this.sub.add( + this.route.paramMap.subscribe((pm: ParamMap) => { + this.imageSpecRoute = pm.get('image_spec') ?? ''; + this.buildSidebarItems(); + this.setFallbackImageName(); + this.rbdImageResourceStateService.load(this.imageSpecRoute); + }) + ); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private setFallbackImageName(): void { + if (!this.imageSpecRoute) { + this.imageName = ''; + return; + } + + try { + const imageSpec = ImageSpec.fromString(decodeURIComponent(this.imageSpecRoute)); + this.imageName = imageSpec.imageName; + } catch { + this.imageName = this.imageSpecRoute; + } + } + + private buildSidebarItems(): void { + this.sidebarItems = [ + { + label: $localize`Overview`, + route: [this.basePath, this.imageSpecRoute, 'overview'], + routerLinkActiveOptions: { exact: true } + }, + { + label: $localize`Snapshots`, + route: [this.basePath, this.imageSpecRoute, 'snapshots'], + routerLinkActiveOptions: { exact: true } + }, + { + label: $localize`Configuration`, + route: [this.basePath, this.imageSpecRoute, 'configuration'], + routerLinkActiveOptions: { exact: true } + }, + { + label: $localize`Performance`, + route: [this.basePath, this.imageSpecRoute, 'performance'], + routerLinkActiveOptions: { exact: true } + } + ]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html index c51b136350d..0fc141a7d29 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html @@ -11,12 +11,11 @@ [count]="count" forceIdentifier="true" selectionType="single" - [hasDetails]="true" + [hasDetails]="false" [status]="tableStatus" [maxLimit]="25" [autoReload]="-1" (fetchData)="taskListService.fetch($event)" - (setExpandedRow)="setExpandedRow($event)" (updateSelection)="updateSelection($event)" > - - - {{ value }} + + {{ value }} + + + {{ value }} + { ], declarations: [ RbdListComponent, - RbdDetailsComponent, RbdSnapshotListComponent, RbdConfigurationListComponent, RbdTabsComponent diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts index b286e35a5c4..c11ef296c7e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts @@ -407,6 +407,12 @@ export class RbdListComponent extends ListWithDetails implements OnInit { image.mirror_mode = scheduling; scheduling = []; } + + image.cdLink = `/block/rbd/${new ImageSpec( + image.pool_name, + image.namespace, + image.name + ).toStringEncoded()}/overview`; }); if (images.length > 0) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-image-resource-state.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-image-resource-state.service.spec.ts new file mode 100644 index 00000000000..0957eda32de --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-image-resource-state.service.spec.ts @@ -0,0 +1,101 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { RbdImageResourceStateService } from './rbd-image-resource-state.service'; +import { RbdService } from '~/app/shared/api/rbd.service'; +import { ImageSpec } from '~/app/shared/models/image-spec'; +import { RbdFormModel } from '../../ceph/block/rbd-form/rbd-form.model'; + +describe('RbdImageResourceStateService', () => { + let service: RbdImageResourceStateService; + let rbdServiceSpy: any; + + beforeEach(() => { + rbdServiceSpy = { + get: jest.fn() + }; + + TestBed.configureTestingModule({ + providers: [RbdImageResourceStateService, { provide: RbdService, useValue: rbdServiceSpy }] + }); + + service = TestBed.inject(RbdImageResourceStateService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('load()', () => { + it('should emit null if imageSpecRoute is empty', (done) => { + service.image$.pipe(take(1)).subscribe((image) => { + expect(image).toBeNull(); + done(); + }); + + service.load(''); + }); + + it('should emit null if imageSpecRoute is null or undefined', (done) => { + service.image$.pipe(take(1)).subscribe((image) => { + expect(image).toBeNull(); + done(); + }); + + service.load(null as any); + }); + + it('should emit null if parsing ImageSpec fails', (done) => { + // Force the static method to throw an error + jest.spyOn(ImageSpec, 'fromString').mockImplementation(() => { + throw new Error('Invalid format'); + }); + + service.image$.pipe(take(1)).subscribe((image) => { + expect(image).toBeNull(); + expect(rbdServiceSpy.get).not.toHaveBeenCalled(); + done(); + }); + + service.load('invalid-format-string'); + }); + + it('should call rbdService.get and emit the image on success', (done) => { + const mockImageSpec = new ImageSpec('test-pool', 'test-namespace', 'test-image'); + const mockResponse: Partial = { name: 'test-image', pool_name: 'test-pool' }; + + jest.spyOn(ImageSpec, 'fromString').mockReturnValue(mockImageSpec); + rbdServiceSpy.get.mockReturnValue(of(mockResponse)); + + service.image$.pipe(take(1)).subscribe((image) => { + expect(ImageSpec.fromString).toHaveBeenCalledWith('test-pool/test-image'); // Decoded URL string + expect(rbdServiceSpy.get).toHaveBeenCalledWith(mockImageSpec); + expect(image).toEqual(mockResponse); + done(); + }); + + service.load('test-pool%2Ftest-image'); // URL Encoded string + }); + + it('should emit null if rbdService.get returns an error', (done) => { + const mockImageSpec = new ImageSpec('test-pool', 'test-namespace', 'test-image'); + + jest.spyOn(ImageSpec, 'fromString').mockReturnValue(mockImageSpec); + // Simulate an HTTP error from the API + rbdServiceSpy.get.mockReturnValue(throwError(() => new Error('API Error'))); + + service.image$.pipe(take(1)).subscribe((image) => { + expect(rbdServiceSpy.get).toHaveBeenCalledWith(mockImageSpec); + expect(image).toBeNull(); + done(); + }); + + service.load('test-pool%2Ftest-image'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-image-resource-state.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-image-resource-state.service.ts new file mode 100644 index 00000000000..0d01ec37cc3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-image-resource-state.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ReplaySubject } from 'rxjs'; + +import { RbdService } from '~/app/shared/api/rbd.service'; +import { ImageSpec } from '~/app/shared/models/image-spec'; +import { RbdFormModel } from '../../ceph/block/rbd-form/rbd-form.model'; + +@Injectable() +export class RbdImageResourceStateService { + private imageSource = new ReplaySubject(1); + + readonly image$ = this.imageSource.asObservable(); + + constructor(private rbdService: RbdService) {} + + load(imageSpecRoute: string): void { + if (!imageSpecRoute) { + this.imageSource.next(null); + return; + } + + try { + const imageSpec = ImageSpec.fromString(decodeURIComponent(imageSpecRoute)); + this.rbdService.get(imageSpec).subscribe({ + next: (image: RbdFormModel) => this.imageSource.next(image), + error: () => this.imageSource.next(null) + }); + } catch { + this.imageSource.next(null); + } + } +}