From: Dnyaneshwari Talwekar Date: Thu, 25 Jun 2026 13:23:36 +0000 (+0530) Subject: mgr/dashboard: Cephfs Mirroring - Add Mirror Button and Tearsheet X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=8d34e0352c074d46c5f5b896df9d53b35cbe6fe2;p=ceph.git mgr/dashboard: Cephfs Mirroring - Add Mirror Button and Tearsheet Fixes: https://tracker.ceph.com/issues/77699 Signed-off-by: Dnyaneshwari Talwekar --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 450f048b627..f90dfe258c3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -66,6 +66,7 @@ import { SmbUsersgroupsListComponent } from './ceph/smb/smb-usersgroups-list/smb import { SmbOverviewComponent } from './ceph/smb/smb-overview/smb-overview.component'; import { MultiClusterFormComponent } from './ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component'; import { CephfsMirroringListComponent } from './ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component'; +import { CephfsAddMirroringPathComponent } from './ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component'; import { CephfsMirroringFsTabsComponent } from './ceph/cephfs/cephfs-mirroring-fs-tabs/cephfs-mirroring-fs-tabs.component'; import { CephfsMirroringFsOverviewComponent } from './ceph/cephfs/cephfs-mirroring-fs-overview/cephfs-mirroring-fs-overview.component'; import { CephfsMirroringFsMirrorPathsComponent } from './ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component'; @@ -496,7 +497,14 @@ const routes: Routes = [ data: { breadcrumbs: 'File/Mirroring', pageHeader: CEPHFS_MIRRORING_PAGE_HEADER - } + }, + children: [ + { + path: 'add-path/:fsId/:fsName', + component: CephfsAddMirroringPathComponent, + outlet: 'modal' + } + ] }, { path: ':fsName', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html new file mode 100644 index 00000000000..ce4f53d7288 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html @@ -0,0 +1,17 @@ +Filesystem: {{fsName}} + + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts new file mode 100644 index 00000000000..a0f4a463a83 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts @@ -0,0 +1,111 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; + +import { CephfsAddMirroringPathComponent } from './cephfs-add-mirroring-path.component'; + +describe('CephfsAddMirroringPathComponent', () => { + let component: CephfsAddMirroringPathComponent; + let fixture: ComponentFixture; + let routerNavigateSpy: jest.Mock; + + beforeEach(async () => { + routerNavigateSpy = jest.fn(); + + await TestBed.configureTestingModule({ + declarations: [CephfsAddMirroringPathComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ fsId: '1', fsName: 'testfs' }) + } + } + }, + { + provide: Router, + useValue: { navigate: routerNavigateSpy } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .overrideComponent(CephfsAddMirroringPathComponent, { + set: { template: '' } + }) + .compileComponents(); + + fixture = TestBed.createComponent(CephfsAddMirroringPathComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should read route params on ngOnInit', () => { + component.ngOnInit(); + + expect(component.fsName).toBe('testfs'); + expect(component.fsId).toBe(1); + }); + + it('should define tearsheet metadata', () => { + expect(component.modalHeaderLabel).toBeDefined(); + expect(component.title).toBeDefined(); + expect(component.steps.length).toBe(3); + expect(component.steps.map((step) => step.label)).toEqual(['Paths', 'Schedule', 'Review']); + }); + + it('should close modal outlet on submit', () => { + component.ngOnInit(); + component.onSubmit(); + + expect(routerNavigateSpy).toHaveBeenCalledWith( + ['/cephfs/mirroring', { outlets: { modal: null } }], + { state: { reload: true } } + ); + }); + + it('should close modal outlet on cancel without reload', () => { + component.ngOnInit(); + component.onCancel(); + + expect(routerNavigateSpy).toHaveBeenCalledWith( + ['/cephfs/mirroring', { outlets: { modal: null } }], + { state: undefined } + ); + }); + + it('should decode encoded filesystem name from route params', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + declarations: [CephfsAddMirroringPathComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ fsId: '2', fsName: encodeURIComponent('my fs') }) + } + } + }, + { + provide: Router, + useValue: { navigate: routerNavigateSpy } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .overrideComponent(CephfsAddMirroringPathComponent, { + set: { template: '' } + }) + .compileComponents(); + + const decodedFixture = TestBed.createComponent(CephfsAddMirroringPathComponent); + decodedFixture.componentInstance.ngOnInit(); + + expect(decodedFixture.componentInstance.fsName).toBe('my fs'); + expect(decodedFixture.componentInstance.fsId).toBe(2); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts new file mode 100644 index 00000000000..341279e3d59 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts @@ -0,0 +1,51 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Step } from 'carbon-components-angular'; + +import { CEPHFS_MIRRORING_URL } from '~/app/shared/constants/cephfs.constant'; + +@Component({ + selector: 'cd-cephfs-add-mirroring-path', + templateUrl: './cephfs-add-mirroring-path.component.html', + styleUrls: ['./cephfs-add-mirroring-path.component.scss'], + standalone: false +}) +export class CephfsAddMirroringPathComponent implements OnInit { + fsName = ''; + fsId = 0; + modalHeaderLabel = $localize`Filesystem mirroring`; + title = $localize`Add mirroring path`; + steps: Step[] = [ + { label: $localize`Paths`, invalid: false }, + { label: $localize`Schedule`, invalid: false }, + { label: $localize`Review`, invalid: false } + ]; + isSubmitLoading = false; + + private route = inject(ActivatedRoute); + private router = inject(Router); + + ngOnInit(): void { + this.fsId = Number(this.route.snapshot.paramMap.get('fsId')); + const fsName = this.route.snapshot.paramMap.get('fsName') ?? ''; + try { + this.fsName = decodeURIComponent(fsName); + } catch { + this.fsName = fsName; + } + } + + onSubmit(): void { + this.closeTearsheet(true); + } + + onCancel(): void { + this.closeTearsheet(false); + } + + private closeTearsheet(reload: boolean): void { + this.router.navigate([CEPHFS_MIRRORING_URL, { outlets: { modal: null } }], { + state: reload ? { reload: true } : undefined + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path.model.ts new file mode 100644 index 00000000000..f8f6a21cb26 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path.model.ts @@ -0,0 +1,5 @@ +export interface MirroringPathSelection { + path: string; + subvol?: string; + group?: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html index 889cdd52fb2..fa1f58d7ede 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html @@ -1,66 +1,50 @@
-

Jump in

+ Jump in
-
+ class="padding-inline-0 jump-in-grid" + [useCssGrid]="true" + [narrow]="true" + [fullWidth]="true"> + @for (tile of jumpInTiles; track tile.title) {
- -
-

Set up mirroring

-

- Configure mirroring for a filesystem by importing a token from a peer cluster and adding paths to replicate. -

-
- - -
-
-
+ +
-
- -
-

Prepare to receive

-

- Generate a bootstrap token for a filesystem to allow a peer cluster to replicate data to it. -

-
- - -
-
-
-
-
+ }
-

Mirrored filesystems

+

Mirrored filesystems

+
+ + +
@@ -68,7 +52,7 @@ @if (isPrepareModalOpen) { } @@ -81,3 +65,5 @@ (mirroringSetup)="closeSetupModal()"> } + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss index 15b64672187..1a8b5cae759 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss @@ -1,11 +1,5 @@ -.cds--grid.cds--grid--narrow { - margin-left: 20px; - padding-left: 0; -} - -.jump-in-row { - align-items: stretch; - +.jump-in-grid { + cd-clickable-tile, cds-clickable-tile, .cds--tile { height: 100%; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts index 626fb56ddfa..9038d279e71 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts @@ -1,24 +1,47 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; import { of } from 'rxjs'; import { CephfsMirroringListComponent } from './cephfs-mirroring-list.component'; import { CephfsService } from '~/app/shared/api/cephfs.service'; import { Daemon, MirroringRow } from '~/app/shared/models/cephfs.model'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { Permission } from '~/app/shared/models/permissions'; describe('CephfsMirroringListComponent', () => { let component: CephfsMirroringListComponent; let fixture: ComponentFixture; + let routerNavigateSpy: jest.Mock; const cephfsServiceMock = { listDaemonStatus: jest.fn() }; + const authStorageServiceMock = { + getPermissions: jest.fn().mockReturnValue({ cephfs: {} as Permission }) + }; + beforeEach(async () => { jest.clearAllMocks(); + routerNavigateSpy = jest.fn(); await TestBed.configureTestingModule({ declarations: [CephfsMirroringListComponent], - providers: [{ provide: CephfsService, useValue: cephfsServiceMock }] + providers: [ + { provide: CephfsService, useValue: cephfsServiceMock }, + { provide: AuthStorageService, useValue: authStorageServiceMock }, + { + provide: Router, + useValue: { + navigate: routerNavigateSpy, + url: '/cephfs/mirroring', + events: of() + } + } + ], + schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); fixture = TestBed.createComponent(CephfsMirroringListComponent); @@ -29,6 +52,7 @@ describe('CephfsMirroringListComponent', () => { cephfsServiceMock.listDaemonStatus.mockReturnValue(of([])); component.ngOnInit(); + expect(component.jumpInTiles.length).toBe(2); expect(component.columns.length).toBe(6); expect(component.columns[0].prop).toBe('local_fs_name'); }); @@ -88,6 +112,7 @@ describe('CephfsMirroringListComponent', () => { fs_name: 'fsA', client_name: 'clientA', directory_count: 3, + filesystem_id: 10, id: '1-10' }); }); @@ -123,8 +148,34 @@ describe('CephfsMirroringListComponent', () => { fs_name: 'fs2', client_name: '-', directory_count: 5, + filesystem_id: 20, peerId: '-', id: '2-20' }); }); + + it('should not navigate to add path modal when filesystem_id is missing', () => { + component.selection = new CdTableSelection([{ local_fs_name: 'fs1' } as MirroringRow]); + + component.openAddPath(); + + expect(routerNavigateSpy).not.toHaveBeenCalled(); + }); + + it('should navigate to add path modal outlet when a filesystem is selected', () => { + component.selection = new CdTableSelection([ + { local_fs_name: 'fs1', filesystem_id: 10 } as MirroringRow + ]); + + component.openAddPath(); + + expect(routerNavigateSpy).toHaveBeenCalledWith([ + '/cephfs/mirroring', + { + outlets: { + modal: ['add-path', 10, encodeURIComponent('fs1')] + } + } + ]); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts index 4ef96dc6c26..ba93cdbcb80 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts @@ -1,12 +1,19 @@ -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; import { Subject, of } from 'rxjs'; -import { catchError, map, switchMap } from 'rxjs/operators'; +import { catchError, filter, map, switchMap, takeUntil } from 'rxjs/operators'; import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { CEPHFS_MIRRORING_URL } from '~/app/shared/constants/cephfs.constant'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { TableComponent } from '~/app/shared/datatable/table/table.component'; import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { Daemon, Filesystem, MirroringRow, Peer } from '~/app/shared/models/cephfs.model'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { MirroringJumpInTile } from './cephfs-mirroring-list.model'; @Component({ selector: 'cd-cephfs-mirroring-list', @@ -15,12 +22,24 @@ import { Daemon, Filesystem, MirroringRow, Peer } from '~/app/shared/models/ceph standalone: false, encapsulation: ViewEncapsulation.None }) -export class CephfsMirroringListComponent implements OnInit { +export class CephfsMirroringListComponent implements OnInit, OnDestroy { + @ViewChild('table', { static: true }) table: TableComponent; + + private cephfsService = inject(CephfsService); + private authStorageService = inject(AuthStorageService); + private router = inject(Router); + columns: CdTableColumn[]; + tableActions: CdTableAction[]; isSetupModalOpen = false; selection = new CdTableSelection(); + permission = this.authStorageService.getPermissions().cephfs; + isPrepareModalOpen = false; + jumpInTiles: MirroringJumpInTile[] = []; private subject$ = new Subject(); + private destroy$ = new Subject(); + private previousUrl = ''; daemonStatus$ = this.subject$.pipe( switchMap(() => @@ -29,11 +48,8 @@ export class CephfsMirroringListComponent implements OnInit { map((daemons) => this.buildRows(daemons)) ); - isPrepareModalOpen = false; - - constructor(private cephfsService: CephfsService) {} - - ngOnInit() { + ngOnInit(): void { + this.jumpInTiles = this.buildJumpInTiles(); this.columns = [ { name: $localize`Filesystem`, @@ -41,7 +57,7 @@ export class CephfsMirroringListComponent implements OnInit { flexGrow: 2, cellTransformation: CellTemplate.redirect, customTemplateConfig: { - redirectLink: ['/cephfs/mirroring', '::prop', 'overview'] + redirectLink: [CEPHFS_MIRRORING_URL, '::prop', 'overview'] } }, { name: $localize`Destination cluster`, prop: 'remote_cluster_name', flexGrow: 2 }, @@ -50,35 +66,100 @@ export class CephfsMirroringListComponent implements OnInit { { name: $localize`Last sync`, prop: 'last_sync', flexGrow: 2 }, { name: $localize`Replicated paths`, prop: 'directory_count', flexGrow: 2 } ]; + this.tableActions = [ + { + name: $localize`Add mirror path`, + permission: 'update', + icon: Icons.add, + click: () => this.openAddPath(), + disable: (selection: CdTableSelection) => !selection.hasSingleSelection + } + ]; + this.previousUrl = this.router.url; + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntil(this.destroy$) + ) + .subscribe((event) => { + const hadModal = this.previousUrl.includes('(modal:'); + const hasModal = event.urlAfterRedirects.includes('(modal:'); + if (hadModal && !hasModal) { + this.loadDaemonStatus(); + } + this.previousUrl = event.urlAfterRedirects; + }); this.loadDaemonStatus(); } - loadDaemonStatus() { + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + updateSelection(selection: CdTableSelection): void { + this.selection = selection; + } + + loadDaemonStatus(): void { this.subject$.next(); } - openPrepareToReceive() { + openPrepareToReceive(): void { this.isPrepareModalOpen = true; } - closePrepareModal() { + closePrepareModal(): void { this.isPrepareModalOpen = false; this.loadDaemonStatus(); } - onTokenGenerated(_response: any) { + onTokenGenerated(): void { this.loadDaemonStatus(); } - openSetupMirroring() { + openSetupMirroring(): void { this.isSetupModalOpen = true; } - closeSetupModal() { + closeSetupModal(): void { this.isSetupModalOpen = false; this.loadDaemonStatus(); } + openAddPath(): void { + const selected = this.selection.first(); + if (!selected?.filesystem_id || !selected?.local_fs_name) { + return; + } + + this.router.navigate([ + CEPHFS_MIRRORING_URL, + { + outlets: { + modal: ['add-path', selected.filesystem_id, encodeURIComponent(selected.local_fs_name)] + } + } + ]); + } + + private buildJumpInTiles(): MirroringJumpInTile[] { + return [ + { + title: $localize`Set up mirroring`, + description: $localize`Configure mirroring for a filesystem by importing a token from a peer cluster and adding paths to replicate.`, + icon: 'replicate', + action: () => this.openSetupMirroring() + }, + { + title: $localize`Prepare to receive`, + description: $localize`Generate a bootstrap token for a filesystem to allow a peer cluster to replicate data to it.`, + icon: 'share', + action: () => this.openPrepareToReceive() + } + ]; + } + private buildRows(daemons: Daemon[]): MirroringRow[] { const rows: MirroringRow[] = []; if (!daemons?.length) { @@ -86,7 +167,9 @@ export class CephfsMirroringListComponent implements OnInit { } for (const daemon of daemons) { - if (!daemon?.filesystems) continue; + if (!daemon?.filesystems) { + continue; + } for (const fs of daemon.filesystems) { if (fs.peers?.length) { for (const peer of fs.peers) { @@ -107,6 +190,7 @@ export class CephfsMirroringListComponent implements OnInit { fs_name: peer.remote?.fs_name ?? '-', client_name: peer.remote?.client_name ?? '-', directory_count: fs.directory_count ?? 0, + filesystem_id: fs.filesystem_id, id: `${daemon.daemon_id}-${fs.filesystem_id}` }; } @@ -118,6 +202,7 @@ export class CephfsMirroringListComponent implements OnInit { fs_name: fs.name, client_name: '-', directory_count: fs.directory_count ?? 0, + filesystem_id: fs.filesystem_id, peerId: '-', id: `${daemon.daemon_id}-${fs.filesystem_id}` }; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.model.ts new file mode 100644 index 00000000000..75916623884 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.model.ts @@ -0,0 +1,6 @@ +export interface MirroringJumpInTile { + title: string; + description: string; + icon: string; + action: () => void; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts index 6a65257472f..c42edba7e06 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts @@ -33,6 +33,7 @@ import { CephfsMountDetailsComponent } from './cephfs-mount-details/cephfs-mount import { CephfsAuthModalComponent } from './cephfs-auth-modal/cephfs-auth-modal.component'; import { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mirroring-list.component'; import { CephfsMirroringErrorComponent } from './cephfs-mirroring-error/cephfs-mirroring-error.component'; +import { CephfsAddMirroringPathComponent } from './cephfs-add-mirroring-path/cephfs-add-mirroring-path.component'; import { CephfsMirroringFsTabsComponent } from './cephfs-mirroring-fs-tabs/cephfs-mirroring-fs-tabs.component'; import { CephfsMirroringFsOverviewComponent } from './cephfs-mirroring-fs-overview/cephfs-mirroring-fs-overview.component'; import { CephfsMirroringFsMirrorPathsComponent } from './cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component'; @@ -143,7 +144,8 @@ import PendingFilled from '@carbon/icons/es/pending--filled/16'; CephfsMirroringFsSchedulesComponent, CephfsGenerateTokenComponent, CephfsDownloadTokenComponent, - CephfsSetupMirroringComponent + CephfsSetupMirroringComponent, + CephfsAddMirroringPathComponent ], providers: [provideCharts(withDefaultRegisterables())] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.html new file mode 100644 index 00000000000..ca153ee18ab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.html @@ -0,0 +1,11 @@ + +
+
+

{{ title }}

+

{{ description }}

+
+ +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.spec.ts new file mode 100644 index 00000000000..be2b67bdadc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.spec.ts @@ -0,0 +1,43 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ClickableTileComponent } from './clickable-tile.component'; + +describe('ClickableTileComponent', () => { + let component: ClickableTileComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ClickableTileComponent], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(ClickableTileComponent); + component = fixture.componentInstance; + component.title = 'Set up mirroring'; + component.description = 'Configure mirroring for a filesystem.'; + component.icon = 'replicate'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render title and description', () => { + const element: HTMLElement = fixture.nativeElement; + + expect(element.textContent).toContain('Set up mirroring'); + expect(element.textContent).toContain('Configure mirroring for a filesystem.'); + }); + + it('should emit tileClick when the tile is clicked', () => { + const tileClickSpy = jest.fn(); + component.tileClick.subscribe(tileClickSpy); + + fixture.nativeElement.querySelector('cds-clickable-tile')?.dispatchEvent(new Event('click')); + + expect(tileClickSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.ts new file mode 100644 index 00000000000..28a81ad4f3b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.ts @@ -0,0 +1,14 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'cd-clickable-tile', + templateUrl: './clickable-tile.component.html', + standalone: false +}) +export class ClickableTileComponent { + @Input({ required: true }) title!: string; + @Input({ required: true }) description!: string; + @Input({ required: true }) icon!: string; + + @Output() tileClick = new EventEmitter(); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 5fbd0930b45..e78b044a00a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -93,6 +93,7 @@ import { IconComponent } from './icon/icon.component'; import { DetailsCardComponent } from './details-card/details-card.component'; import { ToastComponent } from './notification-toast/notification-toast.component'; import { TearsheetComponent } from './tearsheet/tearsheet.component'; +import { ClickableTileComponent } from './clickable-tile/clickable-tile.component'; // Icons import InfoIcon from '@carbon/icons/es/information/16'; @@ -231,6 +232,7 @@ import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.com ToastComponent, TearsheetComponent, TearsheetStepComponent, + ClickableTileComponent, PageHeaderComponent, SidebarLayoutComponent, NumberWithUnitComponent @@ -277,6 +279,7 @@ import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.com ToastComponent, TearsheetComponent, TearsheetStepComponent, + ClickableTileComponent, PageHeaderComponent, SidebarLayoutComponent, NumberWithUnitComponent diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html index a5b181968b2..1c859cc54b8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html @@ -56,7 +56,7 @@ size="xl" [disabled]="currentStep === 0" (click)="onPrevious()" - i18n>Previous + i18n>{{previousButtonLabel}} } @if (currentStep === lastStep) { + i18n>{{previousButtonLabel}} } @if (currentStep === lastStep) {