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,
// 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 });
// 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 });
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';
IscsiSettingComponent,
IscsiTabsComponent,
IscsiTargetListComponent,
- RbdDetailsComponent,
RbdFormComponent,
+ RbdImageResourceSidebarComponent,
+ RbdImageResourcePageComponent,
RbdNamespaceFormModalComponent,
RbdNamespaceListComponent,
RbdSnapshotListComponent,
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' }
+ }
+ ]
}
]
},
<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
>
+++ /dev/null
-<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 }}@{{ 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>
+++ /dev/null
-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();
- });
-});
+++ /dev/null
-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']}`;
- }
- }
-}
--- /dev/null
+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' }]);
+ });
+});
--- /dev/null
+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;
+ }
+ }
+}
--- /dev/null
+@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
+ >
+}
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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;
+ }
+}
--- /dev/null
+<cd-sidebar-layout
+ class="rbd-image-details-layout"
+ [title]="imageName"
+ [items]="sidebarItems"
+>
+</cd-sidebar-layout>
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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 }
+ }
+ ];
+ }
+}
[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"
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';
],
declarations: [
RbdListComponent,
- RbdDetailsComponent,
RbdSnapshotListComponent,
RbdConfigurationListComponent,
RbdTabsComponent
image.mirror_mode = scheduling;
scheduling = [];
}
+
+ image.cdLink = `/block/rbd/${new ImageSpec(
+ image.pool_name,
+ image.namespace,
+ image.name
+ ).toStringEncoded()}/overview`;
});
if (images.length > 0) {
--- /dev/null
+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');
+ });
+ });
+});
--- /dev/null
+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);
+ }
+ }
+}