]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: migrated images table tabs to resource pages 70410/head
authorSyed Ali Ul Hasan <syedaliulhasan19@gmail.com>
Tue, 21 Jul 2026 22:43:44 +0000 (04:13 +0530)
committerSyed Ali Ul Hasan <syedaliulhasan19@gmail.com>
Fri, 24 Jul 2026 10:05:41 +0000 (15:35 +0530)
- Fixes: https://tracker.ceph.com/issues/78479

Signed-off-by: Syed Ali Ul Hasan <syedaliulhasan19@gmail.com>
22 files changed:
src/pybind/mgr/dashboard/frontend/cypress/e2e/block/images.po.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-breadcrumb.resolver.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-breadcrumb.resolver.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-page/rbd-image-resource-page.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-image-resource-sidebar/rbd-image-resource-sidebar.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-image-resource-state.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-image-resource-state.service.ts [new file with mode: 0644]

index 704f922fb548885aadbc06494b728db7985be17b..ab072a8a07b43fd04ad7b5b1d9da0f038fe2d57f 100644 (file)
@@ -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 });
 
index 2291a091997f632691235991432c27091343ea62..85048dc8d7efbf129b36bb9cecbeed7699b8fff2 100644 (file)
@@ -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' }
+          }
+        ]
       }
     ]
   },
index e98296267c750ea00e0664cc1b49d32b5ad20352..4ace00167549d094836c98b17491bfc66d0ecfe2 100644 (file)
   <div [ngSwitch]="value">
     <span
       *ngSwitchCase="'global'"
+      i18n-ngbTooltip
+      ngbTooltip="This is the global value. No value for this option has been set for this image."
       i18n
       >Global</span
     >
     <strong
       *ngSwitchCase="'image'"
+      i18n-ngbTooltip
+      ngbTooltip="This setting overrides the global value"
       i18n
       >Image</strong
     >
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 (file)
index 10c1d1a..0000000
+++ /dev/null
@@ -1,296 +0,0 @@
-<ng-template #usageNotAvailableTooltipTpl>
-  <ng-container i18n
-    >Only available for RBD images with <strong>fast-diff</strong> enabled</ng-container
-  >
-</ng-template>
-
-<ng-container *ngIf="selection && selection.source !== 'REMOVING'">
-  <nav
-    ngbNav
-    #nav="ngbNav"
-    class="nav-tabs"
-    cdStatefulTab="rbd-details"
-  >
-    <ng-container ngbNavItem="details">
-      <a
-        ngbNavLink
-        i18n
-        >Details</a
-      >
-      <ng-template ngbNavContent>
-        <table
-          class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
-          data-testid="rbd-details-table"
-        >
-          <tbody>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold w-25"
-                cdstabledata
-              >
-                Name
-              </td>
-              <td class="w-75">{{ selection.name }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-                cdstabledata
-              >
-                Pool
-              </td>
-              <td>{{ selection.pool_name }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Data Pool
-              </td>
-              <td>{{ selection.data_pool | empty }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Created
-              </td>
-              <td>{{ selection.timestamp | cdDate }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Size
-              </td>
-              <td>{{ selection.size | dimlessBinary }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Objects
-              </td>
-              <td>{{ selection.num_objs | dimless }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Object size
-              </td>
-              <td>{{ selection.obj_size | dimlessBinary }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Features
-              </td>
-              <td>
-                <span *ngFor="let feature of selection.features_name">
-                  <cds-tag class="tag-dark me-2">{{ feature }}</cds-tag>
-                </span>
-              </td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Provisioned
-              </td>
-              <td>
-                <span *ngIf="selection.features_name?.indexOf('fast-diff') === -1">
-                  <span
-                    class="form-text text-muted"
-                    [ngbTooltip]="usageNotAvailableTooltipTpl"
-                    placement="top"
-                    i18n
-                    >N/A</span
-                  >
-                </span>
-                <span *ngIf="selection.features_name?.indexOf('fast-diff') !== -1">
-                  {{ selection.disk_usage | dimlessBinary }}
-                </span>
-              </td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Total provisioned
-              </td>
-              <td>
-                <span *ngIf="selection.features_name?.indexOf('fast-diff') === -1">
-                  <span
-                    class="form-text text-muted"
-                    [ngbTooltip]="usageNotAvailableTooltipTpl"
-                    placement="top"
-                    i18n
-                    >N/A</span
-                  >
-                </span>
-                <span *ngIf="selection.features_name?.indexOf('fast-diff') !== -1">
-                  {{ selection.total_disk_usage | dimlessBinary }}
-                </span>
-              </td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Striping unit
-              </td>
-              <td>{{ selection.stripe_unit | dimlessBinary }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Striping count
-              </td>
-              <td>{{ selection.stripe_count }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Parent
-              </td>
-              <td>
-                <span *ngIf="selection.parent"
-                  >{{ selection.parent.pool_name
-                  }}<span *ngIf="selection.parent.pool_namespace"
-                    >/{{ selection.parent.pool_namespace }}</span
-                  >/{{ selection.parent.image_name }}&#64;{{ selection.parent.snap_name }}</span
-                >
-                <span *ngIf="!selection.parent">-</span>
-              </td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Block name prefix
-              </td>
-              <td>{{ selection.block_name_prefix }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Order
-              </td>
-              <td>{{ selection.order }}</td>
-            </tr>
-            <tr cdstablerow>
-              <td
-                i18n
-                class="bold"
-              >
-                Format Version
-              </td>
-              <td>{{ selection.image_format }}</td>
-            </tr>
-          </tbody>
-        </table>
-      </ng-template>
-    </ng-container>
-    <ng-container ngbNavItem="snapshots">
-      <a
-        ngbNavLink
-        i18n
-        >Snapshots</a
-      >
-      <ng-template ngbNavContent>
-        <cd-rbd-snapshot-list
-          [snapshots]="selection.snapshots"
-          [featuresName]="selection.features_name"
-          [poolName]="selection.pool_name"
-          [primary]="selection.primary"
-          [namespace]="selection.namespace"
-          [mirroring]="selection.mirror_mode"
-          [rbdName]="selection.name"
-        ></cd-rbd-snapshot-list>
-      </ng-template>
-    </ng-container>
-    <ng-container ngbNavItem="configuration">
-      <a
-        ngbNavLink
-        i18n
-        >Configuration</a
-      >
-      <ng-template ngbNavContent>
-        <cd-rbd-configuration-table
-          [data]="selection['configuration']"
-        ></cd-rbd-configuration-table>
-      </ng-template>
-    </ng-container>
-
-    <ng-container ngbNavItem="performance">
-      <a
-        ngbNavLink
-        i18n
-        >Performance</a
-      >
-      <ng-template ngbNavContent>
-        <cd-grafana
-          i18n-title
-          title="RBD details"
-          [grafanaPath]="rbdDashboardUrl"
-          [type]="'metrics'"
-          uid="YhCYGcuZz"
-          grafanaStyle="one"
-        >
-        </cd-grafana>
-      </ng-template>
-    </ng-container>
-  </nav>
-
-  <div [ngbNavOutlet]="nav"></div>
-</ng-container>
-<ng-container *ngIf="selection && selection.source === 'REMOVING'">
-  <cd-alert-panel
-    type="warning"
-    i18n
-    >Information can not be displayed for RBD in status 'Removing'.</cd-alert-panel
-  >
-</ng-container>
-
-<ng-template
-  #poolConfigurationSourceTpl
-  let-row="data.row"
-  let-value="data.value"
->
-  <ng-container *ngIf="+value; else global">
-    <strong
-      i18n
-      i18n-ngbTooltip
-      ngbTooltip="This setting overrides the global value"
-      >Image</strong
-    >
-  </ng-container>
-  <ng-template #global>
-    <span
-      i18n
-      i18n-ngbTooltip
-      ngbTooltip="This is the global value. No value for this option has been set for this image."
-      >Global</span
-    >
-  </ng-template>
-</ng-template>
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 (file)
index e69de29..0000000
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 (file)
index 7579765..0000000
+++ /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<RbdDetailsComponent>;
-
-  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 (file)
index 66982ba..0000000
+++ /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<any>;
-
-  @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 (file)
index 0000000..629341a
--- /dev/null
@@ -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 (file)
index 0000000..0ff7ec8
--- /dev/null
@@ -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 (file)
index 0000000..42c2bfb
--- /dev/null
@@ -0,0 +1,50 @@
+@if (selection && selection.source !== 'REMOVING') {
+  @switch (section) {
+    @case ('overview') {
+      <cd-resource-overview-card
+        i18n-title
+        title="Image details"
+        [columns]="4"
+        [fields]="overviewFields"
+      >
+      </cd-resource-overview-card>
+    }
+    @case ('snapshots') {
+      <cd-rbd-snapshot-list
+        [snapshots]="selection.snapshots"
+        [featuresName]="selection.features_name"
+        [poolName]="selection.pool_name"
+        [primary]="selection.primary"
+        [namespace]="selection.namespace"
+        [mirroring]="selection.mirror_mode"
+        [rbdName]="selection.name"
+      ></cd-rbd-snapshot-list>
+    }
+    @case ('configuration') {
+      <cd-rbd-configuration-table [data]="selection['configuration']"></cd-rbd-configuration-table>
+    }
+    @case ('performance') {
+      <cd-grafana
+        i18n-title
+        title="RBD details"
+        [grafanaPath]="rbdDashboardUrl"
+        [type]="'metrics'"
+        uid="YhCYGcuZz"
+        grafanaStyle="one"
+      >
+      </cd-grafana>
+    }
+  }
+} @else if (selection && selection.source === 'REMOVING') {
+  <cd-alert-panel
+    type="warning"
+    i18n
+    >Information can not be displayed for RBD in status 'Removing'.</cd-alert-panel
+  >
+} @else if (notFound) {
+  <cd-alert-panel
+    type="error"
+    i18n
+    >No RBD image found.</cd-alert-panel
+  >
+}
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..a601d3d
--- /dev/null
@@ -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<RbdImageResourcePageComponent>;
+  let rbdConfigurationServiceSpy: jest.Mocked<RbdConfigurationService>;
+  let imageSubject: BehaviorSubject<RbdFormModel | null>;
+
+  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<RbdFormModel | null>(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 (file)
index 0000000..c74f9d9
--- /dev/null
@@ -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 (file)
index 0000000..030ad81
--- /dev/null
@@ -0,0 +1,6 @@
+<cd-sidebar-layout
+  class="rbd-image-details-layout"
+  [title]="imageName"
+  [items]="sidebarItems"
+>
+</cd-sidebar-layout>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..2486a67
--- /dev/null
@@ -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<RbdImageResourceSidebarComponent>;
+  let stateServiceMock: any;
+  let paramMapSubject: Subject<any>;
+
+  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 (file)
index 0000000..d5308c8
--- /dev/null
@@ -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 }
+      }
+    ];
+  }
+}
index c51b136350d453623a59558d2261a2d0b7b549b1..0fc141a7d290d698d5fe23e3d841518846094a34 100644 (file)
   [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)"
 >
   <cd-table-actions
     [tableActions]="tableActions"
   >
   </cd-table-actions>
-  <cd-rbd-details
-    *cdTableDetail
-    [selection]="expandedRow"
-  >
-  </cd-rbd-details>
 </cd-table>
 
 <ng-template
 >
   <cds-inline-loading *ngIf="row.cdExecuting"></cds-inline-loading>
   <span [ngClass]="column?.customTemplateConfig?.valueClass">
-    {{ value }}
+    <a
+      *ngIf="row?.cdLink; else plainValue"
+      [routerLink]="[row.cdLink]"
+      [queryParams]="row.cdParams"
+    >
+      {{ value }}
+    </a>
+    <ng-template #plainValue>
+      {{ value }}
+    </ng-template>
   </span>
   <span
     *ngIf="row.cdExecuting"
index 9d9258713530fb0185bd824dbb26993bea0d0c53..34c0e379eaea8899626c0bcdb050f0ebd4d1033a 100644 (file)
@@ -16,7 +16,6 @@ import { TaskListService } from '~/app/shared/services/task-list.service';
 import { SharedModule } from '~/app/shared/shared.module';
 import { configureTestBed, expectItemTasks, PermissionHelper } from '~/testing/unit-test-helper';
 import { RbdConfigurationListComponent } from '../rbd-configuration-list/rbd-configuration-list.component';
-import { RbdDetailsComponent } from '../rbd-details/rbd-details.component';
 import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
 import { RbdTabsComponent } from '../rbd-tabs/rbd-tabs.component';
 import { RbdListComponent } from './rbd-list.component';
@@ -44,7 +43,6 @@ describe('RbdListComponent', () => {
     ],
     declarations: [
       RbdListComponent,
-      RbdDetailsComponent,
       RbdSnapshotListComponent,
       RbdConfigurationListComponent,
       RbdTabsComponent
index b286e35a5c4fd4ab73384b89ace22808d7b11f8c..c11ef296c7e56b78f46161e0115c291de6e2b5b0 100644 (file)
@@ -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 (file)
index 0000000..0957eda
--- /dev/null
@@ -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<RbdFormModel> = { 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 (file)
index 0000000..0d01ec3
--- /dev/null
@@ -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<RbdFormModel | null>(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);
+    }
+  }
+}