]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Cephfs Mirroring - Add Mirror Button and Tearsheet
authorDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Thu, 25 Jun 2026 13:23:36 +0000 (18:53 +0530)
committerPedro Gonzalez Gomez <pegonzal@ibm.com>
Wed, 1 Jul 2026 05:31:25 +0000 (07:31 +0200)
Fixes: https://tracker.ceph.com/issues/77699
Signed-off-by: Dnyaneshwari Talwekar <dtalweka@redhat.com>
22 files changed:
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/cephfs.constant.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts

index 450f048b62755f9bb1d01defc7113db25cfa72bd..f90dfe258c3292662c98123b71072b771b9ae674 100644 (file)
@@ -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 (file)
index 0000000..ce4f53d
--- /dev/null
@@ -0,0 +1,17 @@
+<ng-template #descTpl>Filesystem: <strong>{{fsName}}</strong></ng-template>
+
+<cd-tearsheet
+  [steps]="steps"
+  [title]="title"
+  [modalHeaderLabel]="modalHeaderLabel"
+  [descriptionTemplate]="descTpl"
+  [isSubmitLoading]="isSubmitLoading"
+  progressPosition="top"
+  submitButtonLabel="Add mirror path"
+  i18n-submitButtonLabel
+  (submitRequested)="onSubmit()"
+  (closeRequested)="onCancel()">
+  <cd-tearsheet-step></cd-tearsheet-step>
+  <cd-tearsheet-step></cd-tearsheet-step>
+  <cd-tearsheet-step></cd-tearsheet-step>
+</cd-tearsheet>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..a0f4a46
--- /dev/null
@@ -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<CephfsAddMirroringPathComponent>;
+  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 (file)
index 0000000..341279e
--- /dev/null
@@ -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 (file)
index 0000000..f8f6a21
--- /dev/null
@@ -0,0 +1,5 @@
+export interface MirroringPathSelection {
+  path: string;
+  subvol?: string;
+  group?: string;
+}
index 889cdd52fb2e4a0ba8b69e6618c14a00657f72f8..fa1f58d7edeb63ac578a71028e9ca8a5aad04e8b 100644 (file)
@@ -1,66 +1,50 @@
 <div cdsStack="vertical"
      gap="5"
      class="cds-mt-6">
-  <h4 class="cds--type-heading-03"
-      i18n>Jump in</h4>
+  <span class="cds--type-heading-03"
+        i18n>Jump in</span>
   <div cdsGrid
-       [narrow]="true">
-    <div cdsRow
-         class="jump-in-row">
+       class="padding-inline-0 jump-in-grid"
+       [useCssGrid]="true"
+       [narrow]="true"
+       [fullWidth]="true">
+    @for (tile of jumpInTiles; track tile.title) {
       <div cdsCol
            [columnNumbers]="{lg: 4, md: 4, sm: 4}">
-        <cds-clickable-tile (click)="openSetupMirroring()">
-          <div cdsStack="vertical"
-               gap="5">
-            <p class="cds--type-heading-compact-01"
-               i18n>Set up mirroring</p>
-            <p class="cds--type-body-compact-01"
-               i18n>
-              Configure mirroring for a filesystem by importing a token from a peer cluster and adding paths to replicate.
-            </p>
-            <div [cdsStack]="'horizontal'">
-              <cd-icon type="replicate"
-                       size="24"
-                       class="cds-ml-1 cds-mr-12"></cd-icon>
-              <span class="cds--text-right">&rarr;</span>
-            </div>
-          </div>
-        </cds-clickable-tile>
+        <cd-clickable-tile
+          [title]="tile.title"
+          [description]="tile.description"
+          [icon]="tile.icon"
+          (tileClick)="tile.action()">
+        </cd-clickable-tile>
       </div>
-      <div cdsCol
-           [columnNumbers]="{lg: 4, md: 4, sm: 4}">
-        <cds-clickable-tile (click)="openPrepareToReceive(); $event.preventDefault()">
-          <div cdsStack="vertical"
-               gap="5">
-            <p class="cds--type-heading-compact-01"
-               i18n>Prepare to receive</p>
-            <p class="cds--type-body-compact-01"
-               i18n>
-              Generate a bootstrap token for a filesystem to allow a peer cluster to replicate data to it.
-            </p>
-            <div [cdsStack]="'horizontal'">
-              <cd-icon type="share"
-                       size="24"
-                       class="cds-ml-3 cds-mr-12"></cd-icon>
-              <span class="cds--text-right">&rarr;</span>
-            </div>
-          </div>
-        </cds-clickable-tile>
-      </div>
-    </div>
+    }
   </div>
 </div>
 
 <!-- Mirrored filesystems table -->
 <section class="cds-mt-6">
-  <h3 class="cds--type-heading-03 cds-mb-3"
-      i18n>Mirrored filesystems</h3>
+  <p class="cds--type-heading-03 cds-mb-3"
+     i18n>Mirrored filesystems</p>
   <cd-table
+    #table
     [data]="daemonStatus$ | async"
     [columns]="columns"
     columnMode="flex"
     selectionType="single"
+    emptyStateTitle="No mirrored filesystems"
+    i18n-emptyStateTitle
+    emptyStateMessage="Set up mirroring to protect critical workloads by replicating data to a secondary cluster."
+    i18n-emptyStateMessage
+    (updateSelection)="updateSelection($event)"
     (fetchData)="loadDaemonStatus()">
+    <div class="table-actions">
+      <cd-table-actions
+        [permission]="permission"
+        [selection]="selection"
+        [tableActions]="tableActions">
+      </cd-table-actions>
+    </div>
   </cd-table>
 </section>
 
@@ -68,7 +52,7 @@
 @if (isPrepareModalOpen) {
   <cd-cephfs-generate-token
     [open]="true"
-    (tokenGenerated)="onTokenGenerated($event)"
+    (tokenGenerated)="onTokenGenerated()"
     (cancelled)="closePrepareModal()">
   </cd-cephfs-generate-token>
 }
@@ -81,3 +65,5 @@
     (mirroringSetup)="closeSetupModal()">
   </cd-cephfs-setup-mirroring>
 }
+
+<router-outlet name="modal"></router-outlet>
index 15b64672187ad5d09f2c074224ff35145b7cc597..1a8b5cae759448f589e99268d69a391ba00b497a 100644 (file)
@@ -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%;
index 626fb56ddfa366638aef6ba9f6e91c066b541ad9..9038d279e71c4b9a87a197e7952b6b94b534bce2 100644 (file)
@@ -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<CephfsMirroringListComponent>;
+  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')]
+        }
+      }
+    ]);
+  });
 });
index 4ef96dc6c26135a7d4347f4b54617e1a78109093..ba93cdbcb8097eebb2751d2287b177c5fbdf7b70 100644 (file)
@@ -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<void>();
+  private destroy$ = new Subject<void>();
+  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 (file)
index 0000000..7591662
--- /dev/null
@@ -0,0 +1,6 @@
+export interface MirroringJumpInTile {
+  title: string;
+  description: string;
+  icon: string;
+  action: () => void;
+}
index 6a65257472f4816af98721ff41e2f1301cba3b44..c42edba7e0681aafcdfe805bc1424832f5d2cf28 100644 (file)
@@ -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 (file)
index 0000000..ca153ee
--- /dev/null
@@ -0,0 +1,11 @@
+<cds-clickable-tile (click)="tileClick.emit()">
+  <div cdsStack="vertical"
+       gap="5">
+    <div>
+      <p class="cds--type-heading-compact-01">{{ title }}</p>
+      <p class="cds--type-body-compact-01">{{ description }}</p>
+    </div>
+    <cd-icon [type]="icon"
+             size="24"></cd-icon>
+  </div>
+</cds-clickable-tile>
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 (file)
index 0000000..be2b67b
--- /dev/null
@@ -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<ClickableTileComponent>;
+
+  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 (file)
index 0000000..28a81ad
--- /dev/null
@@ -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<void>();
+}
index 5fbd0930b45f01fd50c3e72e508d4c49dd608165..e78b044a00aa009c3d3defd768285717886a8486 100644 (file)
@@ -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
index a5b181968b2308da019aca60927f607f82c1a78f..1c859cc54b87d4ad1405fac6e4d87345bc9b8f4d 100644 (file)
@@ -56,7 +56,7 @@
                   size="xl"
                   [disabled]="currentStep === 0"
                   (click)="onPrevious()"
-                  i18n>Previous</button>
+                  i18n>{{previousButtonLabel}}</button>
           }
           @if (currentStep === lastStep) {
           <button cdsButton="primary"
   <!-- Tearsheet Header -->
   <header
     class="tearsheet-header border-subtle-block-end">
+    @if (modalHeaderLabel) {
+    <p class="cds--type-label-01 tearsheet-header-label">
+      {{modalHeaderLabel}}
+    </p>
+    }
     <h4 cdsModalHeaderHeading
         class="cds--type-heading-04 tearsheet-header-title">
         {{title}}
     </h4>
+    @if (descriptionTemplate) {
+    <p class="cds--type-body-02 tearsheet-header-description">
+      <ng-container *ngTemplateOutlet="descriptionTemplate"></ng-container>
+    </p>
+    } @else {
     <p class="cds--type-body-02 tearsheet-header-description">
       {{description}}
     </p>
+    }
   </header>
+  <!-- Top progress indicator -->
+  @if (steps.length > 1 && progressPosition === 'top') {
+  <div class="tearsheet-top-progress">
+    <cds-progress-indicator
+      [steps]="steps"
+      [current]="currentStep"
+      spacing="equal"
+      (stepSelected)="onStepSelect($event)">
+    </cds-progress-indicator>
+  </div>
+  }
   <section cdsGrid
            class="tearsheet-body"
            [condensed]="true"
            [useCssGrid]="true"
            [fullWidth]="true">
     <!-- Tearsheet Left Influencer-->
-    @if (steps.length > 1) {
+    @if (steps.length > 1 && progressPosition === 'left') {
     <div cdsCol
          [columnNumbers]="{lg: 3, md: 3, sm: 3}"
          class="tearsheet-left-influencer border-subtle-inline-end">
     </div>
     }
     <div cdsCol
-         [columnNumbers]="steps.length > 1 ? {lg: 13, md: 13, sm: 13} : {lg: 16, md: 16, sm: 16}"
+         [columnNumbers]="(steps.length > 1 && progressPosition === 'left') ? {lg: 13, md: 13, sm: 13} : {lg: 16, md: 16, sm: 16}"
          class="tearsheet-main">
       @if (showRightInfluencer) {
       <!-- Tearsheet content with right influencer  -->
                 size="xl"
                 [disabled]="currentStep === 0"
                 (click)="onPrevious()"
-                i18n>Previous</button>
+                i18n>{{previousButtonLabel}}</button>
         }
         @if (currentStep === lastStep) {
         <button cdsButton="primary"
index f888e0bdf774fdf4024ab383f9a315007e39d7b7..a512215d13ba80784c213cbd3e98412014553fcf 100644 (file)
   background-color: var(--cds-background);
   padding: var(--cds-spacing-06) var(--cds-spacing-07);
 
+  &-label {
+    color: var(--cds-text-secondary);
+    margin-bottom: var(--cds-spacing-02);
+  }
+
   &-title {
     color: var(--cds-text-primary);
   }
   }
 }
 
+.tearsheet-top-progress {
+  padding: var(--cds-spacing-05) var(--cds-spacing-07);
+  flex-shrink: 0;
+}
+
 // BODY
 .tearsheet-body {
   margin: 0;
index dd91a045c951e6c7b343e171b40684cb769ef64c..1bd5fbdc69d65aebd5cf16dc9e4dd9ea2b5aea64 100644 (file)
@@ -23,6 +23,7 @@ import { Location } from '@angular/common';
 import { ConfirmationModalComponent } from '../confirmation-modal/confirmation-modal.component';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { Subject } from 'rxjs';
+import { ActionLabelsI18n } from '../../constants/app.constants';
 
 export type TearsheetOverflowScroll = 'auto' | 'hidden' | 'visible' | 'scroll';
 
@@ -32,6 +33,9 @@ export type TearsheetOverflowScroll = 'auto' | 'hidden' | 'visible' | 'scroll';
     [title]="title"
     [isSubmitLoading]="isSubmitLoading"
     [description]="description"
+    modalHeaderLabel="Top label header"
+    progressPosition="top"
+    previousButtonLabel="back"
     (submitRequested)="onSubmit()">
   <cd-tearsheet-step>
       <cd-step #tearsheetStep>
@@ -46,11 +50,11 @@ export type TearsheetOverflowScroll = 'auto' | 'hidden' | 'visible' | 'scroll';
 
 @Component({
   selector: 'cd-step',
-  template: `<form></form>,
+  template: `<form></form>`,
   standalone: false
 })
 export class StepComponent implements TearsheetStep {
-formgroup: CdFormGroup;
+  formGroup: CdFormGroup;
 }
 **/
 @Component({
@@ -63,12 +67,16 @@ formgroup: CdFormGroup;
 })
 export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
   @Input() title!: string;
+  @Input() modalHeaderLabel: string;
   @Input() steps!: Array<Step>;
   @Input() description!: string;
+  @Input() descriptionTemplate: TemplateRef<any>;
   @Input() type: 'full' | 'wide' = 'wide';
   @Input() size: 'xs' | 'sm' | 'md' | 'lg' = 'lg';
-  @Input() submitButtonLabel: string = $localize`Create`;
-  @Input() submitButtonLoadingLabel: string = $localize`Creating`;
+  @Input() progressPosition: 'left' | 'top' = 'left';
+  @Input() submitButtonLabel: string;
+  @Input() submitButtonLoadingLabel: string;
+  @Input() previousButtonLabel: string;
   @Input() isSubmitLoading: boolean = false;
   /** When set, applies `overflow` on the tearsheet content area; omit to use stylesheet defaults. */
   @Input() overflowScroll?: TearsheetOverflowScroll;
@@ -126,10 +134,14 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
     private route: ActivatedRoute,
     private location: Location,
     private destroyRef: DestroyRef,
-    private cdr: ChangeDetectorRef
+    private cdr: ChangeDetectorRef,
+    private actionLabels: ActionLabelsI18n
   ) {}
 
   ngOnInit() {
+    this.submitButtonLabel ??= this.actionLabels.CREATE;
+    this.submitButtonLoadingLabel ??= this.actionLabels.CREATING;
+    this.previousButtonLabel ??= this.actionLabels.PREVIOUS;
     this.lastStep = this.steps.length - 1;
     this.hasModalOutlet = this.route.outlet === 'modal';
   }
index 4786fde842517e1e2d3a5334b75ccb116081e503..c3a884d739c13069c151adec358efef578b62a34 100644 (file)
@@ -109,6 +109,8 @@ export class ActionLabelsI18n {
   MOVE: string;
   NEXT: string;
   BACK: string;
+  PREVIOUS: string;
+  CREATING: string;
   CHANGE: string;
   COPY: string;
   CLONE: string;
@@ -199,6 +201,8 @@ export class ActionLabelsI18n {
     /* Wizard wording */
     this.NEXT = $localize`Next`;
     this.BACK = $localize`Back`;
+    this.PREVIOUS = $localize`Previous`;
+    this.CREATING = $localize`Creating`;
 
     /* Non-standard actions */
     this.CLONE = $localize`Clone`;
index 56890ff72140a8627105b7c565e56561688b2b8f..0d97c3c81058a8791876671171f9cd8f81e7ab9d 100644 (file)
@@ -1 +1,2 @@
 export const DEFAULT_SUBVOLUME_GROUP = '_nogroup';
+export const CEPHFS_MIRRORING_URL = '/cephfs/mirroring';
index da39ebc4998ac09ba1b43242cc6db88071e61db8..6053e86346c1c79b2cae875b94a7850dda8bd768 100644 (file)
@@ -48,6 +48,7 @@ export interface MirroringRow {
   local_fs_name?: string;
   client_name: string;
   directory_count: number;
+  filesystem_id?: number;
   peerId?: string;
   id?: string;
 }