From: Dnyaneshwari Talwekar Date: Fri, 15 May 2026 11:22:27 +0000 (+0530) Subject: mgr/dashboard : Cephfs Mirroring Listing X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=893fb3a9ba4a4633dac124158d85c878190ddb45;p=ceph.git mgr/dashboard : Cephfs Mirroring Listing Fixes: https://tracker.ceph.com/issues/76346 Signed-off-by: Dnyaneshwari Talwekar --- diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/page-header.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/page-header.e2e-spec.ts index 9fb83fa4cec..3c00fb530bf 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/page-header.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/page-header.e2e-spec.ts @@ -8,19 +8,21 @@ describe('Page header component', () => { pageHeader.navigateToCephfsMirroring(); }); - it('should display the page header on CephFS Mirroring page', () => { + it('should display the page header on Filesystem Mirroring page', () => { pageHeader.getPageHeader().should('be.visible'); }); it('should show the expected title in the page header', () => { pageHeader.getHeaderTitle().then((text) => { - expect(text.trim()).to.equal('CephFS Mirroring'); + expect(text.trim()).to.equal('Filesystem Mirroring'); }); }); it('should show the expected description in the page header', () => { pageHeader.getHeaderDescription().then((text) => { - expect(text.trim()).to.equal('Centralised view of all CephFS Mirroring relationships.'); + expect(text.trim()).to.equal( + 'Configure mirroring between filesystems and monitor replication status.' + ); }); }); }); 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 5a77bb1f1db..0a18e3caabc 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 @@ -67,7 +67,6 @@ import { SmbOverviewComponent } from './ceph/smb/smb-overview/smb-overview.compo 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 { NotificationsPageComponent } from './core/navigation/notification-panel/notifications-page/notifications-page.component'; -import { CephfsMirroringWizardComponent } from './ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component'; import { CephfsMirroringErrorComponent } from './ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component'; import { OverviewComponent } from './ceph/overview/overview.component'; @@ -489,11 +488,6 @@ const routes: Routes = [ pageHeader: CEPHFS_MIRRORING_PAGE_HEADER } }, - { - path: `mirroring/${URLVerbs.CREATE}`, - component: CephfsMirroringWizardComponent, - data: { breadcrumbs: ActionLabels.CREATE } - }, { path: 'nfs', canActivateChild: [FeatureTogglesGuardService, ModuleStatusGuardService], diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.html deleted file mode 100644 index 0a2a647b9a2..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.html +++ /dev/null @@ -1,78 +0,0 @@ -
-
Select filesystem
-
-
- -
-
Selection requirements
-
    -
  • Only one filesystem can be selected as the mirroring target
  • -
  • The selected filesystem must have an active MDS (Metadata Server)
  • -
  • Ensure sufficient storage capacity for incoming mirrored snapshots
  • -
-
-
-
- -
- - -
- @if (value === 'Active') { - - } - @if (value === 'Warning') { - - } - @if (value === 'Inactive') { - - } - {{ mdsStatusLabels[value] }} -
-
- - -
- @if (value === 'Enabled') { - - } - @if (value === 'Disabled') { - - } - {{ mirroringStatusLabels[value] }} -
-
- - - - - @for (pool of row.pools; track $index; let last = $last) { - {{ pool }} - @if (!last) { - , - } - } - - - {{ row[col.prop] }} - - - -
-
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.scss deleted file mode 100644 index 46d43981012..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -@use '@carbon/layout'; - -.requirements-list { - list-style-type: disc; - padding-left: var(--cds-spacing-05); - margin-left: layout.$spacing-02; -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.spec.ts deleted file mode 100644 index 47f8e054e97..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; - -import { CephfsService } from '~/app/shared/api/cephfs.service'; -import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; -import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; -import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector.component'; - -const createDetail = ( - id: number, - name: string, - pools: Array<{ pool: string; used: number }>, - enabled = true, - peers: Record = { peer: {} } -) => ({ - cephfs: { - id, - name, - pools, - flags: { enabled }, - mirror_info: { peers } - } -}); - -describe('CephfsFilesystemSelectorComponent', () => { - let component: CephfsFilesystemSelectorComponent; - let fixture: ComponentFixture; - let cephfsServiceMock: jest.Mocked>; - - beforeEach(async () => { - cephfsServiceMock = { - list: jest.fn(), - getCephfs: jest.fn() - }; - - cephfsServiceMock.list.mockReturnValue(of([])); - cephfsServiceMock.getCephfs.mockReturnValue(of(null)); - - await TestBed.configureTestingModule({ - declarations: [CephfsFilesystemSelectorComponent], - providers: [{ provide: CephfsService, useValue: cephfsServiceMock }, DimlessBinaryPipe], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); - - fixture = TestBed.createComponent(CephfsFilesystemSelectorComponent); - component = fixture.componentInstance; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should configure columns on init', () => { - fixture.detectChanges(); - - expect(component.columns.map((c) => c.prop)).toEqual([ - 'name', - 'used', - 'pools', - 'mdsStatus', - 'mirroringStatus' - ]); - }); - - it('should populate filesystems from service data', fakeAsync(() => { - cephfsServiceMock.list.mockReturnValue(of([{ id: 1 }])); - cephfsServiceMock.getCephfs.mockReturnValue( - of( - createDetail(1, 'fs1', [ - { pool: 'data', used: 100 }, - { pool: 'meta', used: 50 } - ]) - ) - ); - - fixture.detectChanges(); - - let filesystems: any[] = []; - component.filesystems$.subscribe((rows) => { - filesystems = rows; - }); - tick(); - - expect(filesystems).toEqual([ - { - id: 1, - name: 'fs1', - pools: ['data', 'meta'], - used: '150', - mdsStatus: 'Inactive', - mirroringStatus: 'Disabled' - } - ]); - })); - - it('should set mirroring status to Disabled when list response has no mirror info', fakeAsync(() => { - cephfsServiceMock.list.mockReturnValue(of([{ id: 2 }])); - cephfsServiceMock.getCephfs.mockReturnValue(of(createDetail(2, 'fs2', []))); - - fixture.detectChanges(); - - let filesystems: any[] = []; - component.filesystems$.subscribe((rows) => { - filesystems = rows; - }); - tick(); - - expect(filesystems[0].mirroringStatus).toBe('Disabled'); - })); - - it('should produce empty filesystems when list is empty', fakeAsync(() => { - cephfsServiceMock.list.mockReturnValue(of([])); - - fixture.detectChanges(); - - let filesystems: any[] = []; - component.filesystems$.subscribe((rows) => { - filesystems = rows; - }); - tick(); - - expect(filesystems).toEqual([]); - expect(cephfsServiceMock.getCephfs).not.toHaveBeenCalled(); - })); - - it('should skip null details when getCephfs errors', fakeAsync(() => { - cephfsServiceMock.list.mockReturnValue(of([{ id: 3 }])); - cephfsServiceMock.getCephfs.mockReturnValue(throwError(() => new Error('boom'))); - - fixture.detectChanges(); - - let filesystems: any[] = []; - component.filesystems$.subscribe((rows) => { - filesystems = rows; - }); - tick(); - - expect(filesystems).toEqual([]); - })); - - it('should update selection reference', () => { - const selection = new CdTableSelection([{ id: 1 }]); - component.updateSelection(selection); - expect(component.selection).toBe(selection); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.ts deleted file mode 100644 index d22fb82c2c3..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - Component, - OnInit, - TemplateRef, - ViewChild, - inject, - Output, - EventEmitter -} from '@angular/core'; - -import { CephfsService } from '~/app/shared/api/cephfs.service'; -import { CdTableColumn } from '~/app/shared/models/cd-table-column'; -import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; -import { Icons } from '~/app/shared/enum/icons.enum'; -import { catchError, map, switchMap } from 'rxjs/operators'; -import { forkJoin, of, Observable } from 'rxjs'; -import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; -import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; -import { - CephfsDetail, - FilesystemRow, - MdsStatus, - MirroringStatus, - MIRRORING_STATUS, - mdsStateToStatus -} from '~/app/shared/models/cephfs.model'; - -@Component({ - selector: 'cd-cephfs-filesystem-selector', - templateUrl: './cephfs-filesystem-selector.component.html', - standalone: false, - styleUrls: ['./cephfs-filesystem-selector.component.scss'] -}) -export class CephfsFilesystemSelectorComponent implements OnInit { - @ViewChild('mdsStatus', { static: true }) - mdsStatus: TemplateRef; - @ViewChild('mirroringStatus', { static: true }) - mirroringStatus: TemplateRef; - columns: CdTableColumn[] = []; - filesystems$: Observable = of([]); - selection = new CdTableSelection(); - icons = Icons; - @Output() filesystemSelected = new EventEmitter(); - mdsStatusLabels: Record = { - Active: $localize`Active`, - Warning: $localize`Warning`, - Inactive: $localize`Inactive` - }; - mirroringStatusLabels: Record = { - Enabled: $localize`Enabled`, - Disabled: $localize`Disabled` - }; - - private cephfsService = inject(CephfsService); - private dimlessBinaryPipe = inject(DimlessBinaryPipe); - - ngOnInit(): void { - this.columns = [ - { name: $localize`Filesystem name`, prop: 'name', flexGrow: 2 }, - { name: $localize`Usage`, prop: 'used', flexGrow: 1, pipe: this.dimlessBinaryPipe }, - { - prop: $localize`pools`, - name: 'Pools used', - cellTransformation: CellTemplate.tag, - customTemplateConfig: { - class: 'tag-background-primary' - }, - flexGrow: 1.3 - }, - { name: $localize`Status`, prop: 'mdsStatus', flexGrow: 0.8, cellTemplate: this.mdsStatus }, - - { - name: $localize`Mirroring status`, - prop: 'mirroringStatus', - flexGrow: 0.8, - cellTemplate: this.mirroringStatus - } - ]; - - this.filesystems$ = this.cephfsService.list().pipe( - switchMap((listResponse: Array) => { - if (!listResponse?.length) { - return of([]); - } - const detailRequests = listResponse.map( - (fs): Observable => - this.cephfsService.getCephfs(fs.id).pipe(catchError(() => of(null))) - ); - return forkJoin(detailRequests).pipe( - map((details: Array) => - details - .map((detail, index) => { - if (!detail?.cephfs) { - return null; - } - const listItem = listResponse[index]; - const pools = detail.cephfs.pools || []; - const poolNames = pools.map((p) => p.pool); - const totalUsed = pools.reduce((sum, p) => sum + p.used, 0); - const mdsInfo = listItem?.mdsmap?.info ?? {}; - const firstMdsGid = Object.keys(mdsInfo)[0]; - const mdsState = firstMdsGid ? mdsInfo[firstMdsGid]?.state : undefined; - return { - id: detail.cephfs.id, - name: detail.cephfs.name, - pools: poolNames, - used: `${totalUsed}`, - mdsStatus: mdsStateToStatus(mdsState), - mirroringStatus: listItem?.mirror_info - ? MIRRORING_STATUS.Enabled - : MIRRORING_STATUS.Disabled - } as FilesystemRow; - }) - .filter((row): row is FilesystemRow => row !== null) - ) - ); - }) - ); - } - - updateSelection(selection: CdTableSelection) { - this.selection = selection; - const selectedRow = typeof selection?.first === 'function' ? selection.first() : null; - this.filesystemSelected.emit(selectedRow as FilesystemRow | null); - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html index cb8f101a847..bcbe6b1e510 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html @@ -6,7 +6,7 @@ #formDir="ngForm" [formGroup]="form" novalidate> -
{{ action | titlecase }} {{ resource | upperFirst }}
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 2ba0db47e33..c0c55839e30 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,16 +1,68 @@ -@if (daemonStatus$ | async; as daemonStatus) { +
+

Jump in

+
+
+
+ +
+

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

- - + (fetchData)="loadDaemonStatus()" + (updateSelection)="updateSelection($event)"> -} +
+ 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 e69de29bb2d..15b64672187 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 @@ -0,0 +1,13 @@ +.cds--grid.cds--grid--narrow { + margin-left: 20px; + padding-left: 0; +} + +.jump-in-row { + align-items: stretch; + + 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 bd2f222b24c..d7fa1dbc148 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,9 +1,9 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { CephfsMirroringListComponent } from './cephfs-mirroring-list.component'; import { CephfsService } from '~/app/shared/api/cephfs.service'; -import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; import { Daemon, MirroringRow } from '~/app/shared/models/cephfs.model'; describe('CephfsMirroringListComponent', () => { @@ -19,7 +19,8 @@ describe('CephfsMirroringListComponent', () => { await TestBed.configureTestingModule({ declarations: [CephfsMirroringListComponent], - providers: [ActionLabelsI18n, { provide: CephfsService, useValue: cephfsServiceMock }] + providers: [{ provide: CephfsService, useValue: cephfsServiceMock }], + schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); fixture = TestBed.createComponent(CephfsMirroringListComponent); @@ -29,14 +30,15 @@ describe('CephfsMirroringListComponent', () => { it('should initialize columns correctly on ngOnInit', () => { component.ngOnInit(); - expect(component.columns.length).toBe(5); - expect(component.columns[0].prop).toBe('remote_cluster_name'); + expect(component.columns.length).toBe(6); + expect(component.columns[0].prop).toBe('local_fs_name'); }); - it('should call loadDaemonStatus inside ngOnInit', () => { - const loadSpy = jest.spyOn(component, 'loadDaemonStatus'); - component.ngOnInit(); - expect(loadSpy).toHaveBeenCalledTimes(1); + it('should fetch daemon status when loadDaemonStatus() is called', () => { + cephfsServiceMock.listDaemonStatus.mockReturnValue(of([])); + component.daemonStatus$.subscribe(); + component.loadDaemonStatus(); + expect(cephfsServiceMock.listDaemonStatus).toHaveBeenCalledTimes(1); }); it('should map daemon status to MirroringRow[] correctly', () => { 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 7d9e04bb6a3..355f6dc951b 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,119 +1,97 @@ -import { Component, ViewChild, OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { catchError, switchMap } from 'rxjs/operators'; +import { Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; +import { Subject, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; + +import { CephfsService } from '~/app/shared/api/cephfs.service'; import { TableComponent } from '~/app/shared/datatable/table/table.component'; import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; -import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; -import { CephfsService } from '~/app/shared/api/cephfs.service'; -import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; -import { CdTableAction } from '~/app/shared/models/cd-table-action'; -import { URLBuilderService } from '~/app/shared/services/url-builder.service'; -import { Daemon, MirroringRow } from '~/app/shared/models/cephfs.model'; -import { Icons } from '~/app/shared/enum/icons.enum'; -import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; -import { Permission } from '~/app/shared/models/permissions'; +import { Daemon, Filesystem, MirroringRow, Peer } from '~/app/shared/models/cephfs.model'; -export const MIRRORING_PATH = 'cephfs/mirroring'; @Component({ selector: 'cd-cephfs-mirroring-list', templateUrl: './cephfs-mirroring-list.component.html', styleUrls: ['./cephfs-mirroring-list.component.scss'], standalone: false, - providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(MIRRORING_PATH) }] + encapsulation: ViewEncapsulation.None }) export class CephfsMirroringListComponent implements OnInit { @ViewChild('table', { static: true }) table: TableComponent; columns: CdTableColumn[]; selection = new CdTableSelection(); - subject$ = new BehaviorSubject([]); - daemonStatus$: Observable; - context: CdTableFetchDataContext; - tableActions: CdTableAction[]; - permission: Permission; - constructor( - public actionLabels: ActionLabelsI18n, - private authStorageService: AuthStorageService, - private cephfsService: CephfsService, - private urlBuilder: URLBuilderService - ) { - this.permission = this.authStorageService.getPermissions().cephfs; - } + private subject$ = new Subject(); + + daemonStatus$ = this.subject$.pipe( + switchMap(() => + this.cephfsService.listDaemonStatus().pipe(catchError(() => of([] as Daemon[]))) + ), + map((daemons) => this.buildRows(daemons)) + ); + + constructor(private cephfsService: CephfsService) {} ngOnInit() { this.columns = [ - { - name: $localize`Remote cluster`, - prop: 'remote_cluster_name', - flexGrow: 2 - }, - { name: $localize`Local filesystem`, prop: 'local_fs_name', flexGrow: 2 }, - { name: $localize`Remote filesystem`, prop: 'fs_name', flexGrow: 2 }, - { name: $localize`Remote client`, prop: 'client_name', flexGrow: 2 }, - { name: $localize`Snapshot directories`, prop: 'directory_count', flexGrow: 1 } + { name: $localize`Filesystem`, prop: 'local_fs_name', flexGrow: 2 }, + { name: $localize`Destination cluster`, prop: 'remote_cluster_name', flexGrow: 2 }, + { name: $localize`Mirroring status`, prop: 'mirroring_status', flexGrow: 2 }, + { name: $localize`Bytes replicated`, prop: 'bytes_replicated', flexGrow: 2 }, + { name: $localize`Last sync`, prop: 'last_sync', flexGrow: 2 }, + { name: $localize`Replicated paths`, prop: 'directory_count', flexGrow: 2 } ]; + } - const createAction: CdTableAction = { - permission: 'create', - icon: Icons.add, - routerLink: () => this.urlBuilder.getCreate(), - name: this.actionLabels.CREATE, - canBePrimary: (selection: CdTableSelection) => !selection.hasSelection - }; + loadDaemonStatus() { + this.subject$.next(); + } - this.tableActions = [createAction]; - this.daemonStatus$ = this.subject$.pipe( - switchMap(() => - this.cephfsService.listDaemonStatus()?.pipe( - switchMap((daemons: Daemon[]) => { - const result: MirroringRow[] = []; + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } - daemons.forEach((d) => { - d.filesystems.forEach((fs) => { - if (!fs.peers || fs.peers.length === 0) { - result.push({ - remote_cluster_name: '-', - local_fs_name: fs.name, - fs_name: fs.name, - client_name: '-', - directory_count: fs.directory_count, - peerId: '-', - id: `${d.daemon_id}-${fs.filesystem_id}` - }); - } else { - fs.peers.forEach((peer) => { - result.push({ - remote_cluster_name: peer.remote.cluster_name, - local_fs_name: fs.name, - fs_name: peer.remote.fs_name, - client_name: peer.remote.client_name, - directory_count: fs.directory_count, - id: `${d.daemon_id}-${fs.filesystem_id}` - }); - }); - } - }); - }); - return of(result); - }), - catchError(() => { - this.context?.error(); - return of(null); - }) - ) - ) - ); + private buildRows(daemons: Daemon[]): MirroringRow[] { + const rows: MirroringRow[] = []; + if (!daemons?.length) { + return rows; + } - this.loadDaemonStatus(); + for (const daemon of daemons) { + if (!daemon?.filesystems) continue; + for (const fs of daemon.filesystems) { + if (fs.peers?.length) { + for (const peer of fs.peers) { + rows.push(this.peerToRow(daemon, fs, peer)); + } + } else { + rows.push(this.noPeerRow(daemon, fs)); + } + } + } + return rows; } - loadDaemonStatus() { - this.subject$.next([]); + private peerToRow(daemon: Daemon, fs: Filesystem, peer: Peer): MirroringRow { + return { + remote_cluster_name: peer.remote?.cluster_name ?? '-', + local_fs_name: fs.name, + fs_name: peer.remote?.fs_name ?? '-', + client_name: peer.remote?.client_name ?? '-', + directory_count: fs.directory_count ?? 0, + id: `${daemon.daemon_id}-${fs.filesystem_id}` + }; } - updateSelection(selection: CdTableSelection) { - this.selection = selection; + private noPeerRow(daemon: Daemon, fs: Filesystem): MirroringRow { + return { + remote_cluster_name: '-', + local_fs_name: fs.name, + fs_name: fs.name, + client_name: '-', + directory_count: fs.directory_count ?? 0, + peerId: '-', + id: `${daemon.daemon_id}-${fs.filesystem_id}` + }; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts deleted file mode 100644 index 2b712d810ba..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const StepTitles = { - ChooseMirrorPeerRole: $localize`Choose mirror peer role`, - SelectFilesystem: $localize`Select filesystem`, - CreateOrSelectEntity: $localize`Create or select entity`, - GenerateBootstrapToken: $localize`Generate bootstrap token`, - Review: $localize`Review` -} as const; - -export const STEP_TITLES_MIRRORING_CONFIGURED = [ - StepTitles.ChooseMirrorPeerRole, - StepTitles.SelectFilesystem, - StepTitles.CreateOrSelectEntity, - StepTitles.GenerateBootstrapToken -]; - -export const LOCAL_ROLE = 'local'; -export const REMOTE_ROLE = 'remote'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html deleted file mode 100644 index 5c0f99ec8a1..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html +++ /dev/null @@ -1,116 +0,0 @@ - - -
-
-
Choose mirror peer role
-

Select how the cluster will participate in the CephFS mirroring relationship.

-
- -
-
- - - -
-
Configure local peer
-
- This cluster will act as the initiating peer and send snapshots to a remote - peer. -
-
    - @for (item of sourceList; track $index) { -
  • → {{ item }}
  • - } -
-
-
-
-
-
- - - -
-
Configure remote peer
-
- A remote cluster will act as the receiving peer and store replicated snapshots. -
-
    - @for (item of targetList; track $index) { -
  • → {{ item }}
  • - } -
-
-
-
-
-
- - @if (form.get('localRole')?.value !== LOCAL_ROLE && showMessage) { - -
-
About remote peer setup
-
- As a remote peer, this cluster prepares to receive mirrored data from an initiating - cluster. The setup includes environment validation, enabling filesystem mirroring, - creating required Ceph users, and generating a bootstrap token. -
-
What happens next:
-
    -
  • Environment validation
  • -
  • Ceph user creation
  • -
  • Filesystem mirroring activation
  • -
  • Bootstrap token generation
  • -
-
-
- } -
-
- - - - - - - - - - - - - - - -
- Test 3 -
-
-
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss deleted file mode 100644 index c327f0ffe19..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -form { - max-width: 77%; -} - -.list-disc { - list-style-type: disc; -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.spec.ts deleted file mode 100644 index 67397af8de5..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard.component'; -import { WizardStepsService } from '~/app/shared/services/wizard-steps.service'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; -import { BehaviorSubject } from 'rxjs'; -import { - STEP_TITLES_MIRRORING_CONFIGURED, - LOCAL_ROLE, - REMOTE_ROLE -} from './cephfs-mirroring-wizard-step.enum'; -import { WizardStepModel } from '~/app/shared/models/wizard-steps'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { RadioModule } from 'carbon-components-angular'; - -describe('CephfsMirroringWizardComponent', () => { - let component: CephfsMirroringWizardComponent; - let fixture: ComponentFixture; - let wizardStepsService: jest.Mocked; - let router: jest.Mocked; - - const mockSteps: WizardStepModel[] = [ - { stepIndex: 0, isComplete: false }, - { stepIndex: 1, isComplete: false } - ]; - - beforeEach(async () => { - wizardStepsService = ({ - setTotalSteps: jest.fn(), - setCurrentStep: jest.fn(), - steps$: new BehaviorSubject(mockSteps) - } as unknown) as jest.Mocked; - - router = ({ - navigate: jest.fn() - } as unknown) as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [ReactiveFormsModule, RadioModule], - declarations: [CephfsMirroringWizardComponent], - providers: [ - FormBuilder, - { provide: WizardStepsService, useValue: wizardStepsService }, - { provide: Router, useValue: router } - ], - schemas: [NO_ERRORS_SCHEMA] - }).compileComponents(); - - fixture = TestBed.createComponent(CephfsMirroringWizardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create the component', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize wizard steps on ngOnInit', () => { - expect(wizardStepsService.setTotalSteps).toHaveBeenCalledWith( - STEP_TITLES_MIRRORING_CONFIGURED.length - ); - - expect(component.steps.length).toBe(STEP_TITLES_MIRRORING_CONFIGURED.length); - }); - - it('should navigate to step when goToStep is called', () => { - component.goToStep(mockSteps[0]); - - expect(wizardStepsService.setCurrentStep).toHaveBeenCalledWith(mockSteps[0]); - }); - - it('should initialize form with local role selected', () => { - expect(component.form.value).toEqual({ - localRole: LOCAL_ROLE, - remoteRole: null - }); - }); - - it('should update form on local role change', () => { - component.onLocalRoleChange(); - - expect(component.form.value).toEqual({ - localRole: LOCAL_ROLE, - remoteRole: null - }); - }); - - it('should update form on remote role change', () => { - component.onRemoteRoleChange(); - - expect(component.form.value).toEqual({ - localRole: null, - remoteRole: REMOTE_ROLE - }); - }); - - it('should navigate to mirroring list on cancel', () => { - component.onCancel(); - expect(router.navigate).toHaveBeenCalledWith(['/cephfs/mirroring']); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.ts deleted file mode 100644 index 75554600392..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Component, OnInit, inject } from '@angular/core'; -import { Step } from 'carbon-components-angular'; -import { Router } from '@angular/router'; -import { - STEP_TITLES_MIRRORING_CONFIGURED, - LOCAL_ROLE, - REMOTE_ROLE -} from './cephfs-mirroring-wizard-step.enum'; -import { WizardStepsService } from '~/app/shared/services/wizard-steps.service'; -import { WizardStepModel } from '~/app/shared/models/wizard-steps'; -import { FormBuilder, FormGroup } from '@angular/forms'; -import { FilesystemRow } from '~/app/shared/models/cephfs.model'; -@Component({ - selector: 'cd-cephfs-mirroring-wizard', - templateUrl: './cephfs-mirroring-wizard.component.html', - standalone: false, - styleUrls: ['./cephfs-mirroring-wizard.component.scss'] -}) -export class CephfsMirroringWizardComponent implements OnInit { - steps: Step[] = []; - title: string = $localize`Create new CephFS Mirroring`; - description: string = $localize`Configure a new mirroring relationship between clusters`; - form: FormGroup; - showMessage: boolean = true; - selectedFilesystem: FilesystemRow | null = null; - selectedEntity: string | null = null; - - LOCAL_ROLE = LOCAL_ROLE; - REMOTE_ROLE = REMOTE_ROLE; - - private wizardStepsService = inject(WizardStepsService); - private fb = inject(FormBuilder); - private router = inject(Router); - - sourceList: string[] = [ - $localize`Sends data to remote clusters`, - $localize`Requires bootstrap token from target`, - $localize`Manages snapshot schedules` - ]; - - targetList: string[] = [ - $localize`Receives data from source clusters`, - $localize`Generates bootstrap token`, - $localize`Stores replicated snapshots` - ]; - - constructor() { - this.form = this.fb.group({ - localRole: [LOCAL_ROLE], - remoteRole: [null] - }); - } - - ngOnInit() { - this.wizardStepsService.setTotalSteps(STEP_TITLES_MIRRORING_CONFIGURED.length); - - const stepsData = this.wizardStepsService.steps$.value; - this.steps = STEP_TITLES_MIRRORING_CONFIGURED.map((title, index) => ({ - label: title, - onClick: () => this.goToStep(stepsData[index]), - invalid: true - })); - } - - onFilesystemSelected(filesystem: FilesystemRow) { - this.selectedFilesystem = filesystem; - if (this.steps[1]) { - this.steps[1].invalid = !filesystem; - } - } - - onEntitySelected(entity: string) { - this.selectedEntity = entity; - if (this.steps[2]) { - this.steps[2].invalid = !entity; - } - } - - goToStep(step: WizardStepModel) { - if (step) { - this.wizardStepsService.setCurrentStep(step); - } - } - - onLocalRoleChange() { - this.form.patchValue({ localRole: LOCAL_ROLE, remoteRole: null }); - this.showMessage = false; - if (this.steps[0]) { - this.steps[0].invalid = false; - } - } - - onRemoteRoleChange() { - this.form.patchValue({ localRole: null, remoteRole: REMOTE_ROLE }); - this.showMessage = true; - if (this.steps[0]) { - this.steps[0].invalid = false; - } - } - - onSubmit() {} - - onCancel() { - this.router.navigate(['/cephfs/mirroring']); - } -} 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 92d39708d09..a8e17adbefc 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 @@ -64,9 +64,10 @@ import LaunchIcon from '@carbon/icons/es/launch/32'; import Close from '@carbon/icons/es/close/32'; import Trash from '@carbon/icons/es/trash-can/32'; import Renew16 from '@carbon/icons/es/renew/16'; -import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard/cephfs-mirroring-wizard.component'; -import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector/cephfs-filesystem-selector.component'; -import { CephfsMirroringEntityComponent } from './cephfs-mirroring-entity/cephfs-mirroring-entity.component'; +import ReplicateIcon from '@carbon/icons/es/replicate/32'; +import ReplicateIcon24 from '@carbon/icons/es/replicate/24'; +import ShareIcon from '@carbon/icons/es/share/32'; +import ShareIcon24 from '@carbon/icons/es/share/24'; @NgModule({ imports: [ @@ -127,15 +128,22 @@ import { CephfsMirroringEntityComponent } from './cephfs-mirroring-entity/cephfs CephfsMountDetailsComponent, CephfsAuthModalComponent, CephfsMirroringListComponent, - CephfsMirroringWizardComponent, - CephfsFilesystemSelectorComponent, - CephfsMirroringErrorComponent, - CephfsMirroringEntityComponent + CephfsMirroringErrorComponent ], providers: [provideCharts(withDefaultRegisterables())] }) export class CephfsModule { constructor(private iconService: IconService) { - this.iconService.registerAll([AddIcon, LaunchIcon, Close, Trash, Renew16]); + this.iconService.registerAll([ + AddIcon, + LaunchIcon, + Close, + Trash, + Renew16, + ReplicateIcon, + ReplicateIcon24, + ShareIcon, + ShareIcon24 + ]); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html index dfcd7523a08..eca9ce8442d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html @@ -11,7 +11,7 @@ novalidate >
{{ action | titlecase }} {{ resource | upperFirst }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html index 95d610d26d3..da63bd561f2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html @@ -22,6 +22,7 @@ @if(pageHeaderTitle) { } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts index d7983d6247f..7c149092d1b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts @@ -30,6 +30,7 @@ export class WorkbenchLayoutComponent implements OnInit, OnDestroy { private subs = new Subscription(); permissions: Permissions; pageHeaderTitle: string | null = null; + pageHeaderSubtitle: string | null = null; pageHeaderDescription: string | null = null; enabledFeature$: Observable; @@ -91,9 +92,10 @@ export class WorkbenchLayoutComponent implements OnInit, OnDestroy { route = route.firstChild; } const pageHeader = route?.routeConfig?.data?.['pageHeader'] as - | { title?: string; description?: string } + | { title?: string; subtitle?: string; description?: string } | undefined; this.pageHeaderTitle = pageHeader?.title ?? null; + this.pageHeaderSubtitle = pageHeader?.subtitle ?? null; this.pageHeaderDescription = pageHeader?.description ?? null; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/page-header/page-header.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/page-header/page-header.component.html index 3b51bd5a7e1..069ccd78b00 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/page-header/page-header.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/page-header/page-header.component.html @@ -2,6 +2,9 @@

{{ title }}

+ @if(subtitle) { +

{{ subtitle }}

+ } @if(description) {

{{ description }}

} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/page-header/page-header.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/page-header/page-header.component.ts index cb2ac7d3fdb..f700127f390 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/page-header/page-header.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/page-header/page-header.component.ts @@ -5,7 +5,7 @@ import { Component, Input, ViewEncapsulation } from '@angular/core'; * @see https://ibm-products.carbondesignsystem.com/?path=/docs/components-pageheader--overview * * Usage: - * + * * */ @Component({ @@ -17,5 +17,6 @@ import { Component, Input, ViewEncapsulation } from '@angular/core'; }) export class PageHeaderComponent { @Input({ required: true }) title: string; + @Input() subtitle: string = ''; @Input() description: string = ''; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts index 22673b0ac2d..4786fde8425 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts @@ -388,6 +388,7 @@ export const USER = 'user'; export const VERSION_PREFIX = 'ceph version'; export const CEPHFS_MIRRORING_PAGE_HEADER = { - title: $localize`CephFS Mirroring`, - description: $localize`Centralised view of all CephFS Mirroring relationships.` + title: $localize`Filesystem Mirroring`, + subtitle: $localize`Manage snapshot-based replication for CephFS across clusters.`, + description: $localize`Configure mirroring between filesystems and monitor replication status.` }; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index c5190555b6a..e95cd5fc44e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -29,6 +29,7 @@ export enum Icons { expand = 'maximize', // Expand cluster user = 'user', // User, Initiators users = 'user--multiple', // Users, Groups + replicate = 'replicate', // replicate share = 'share', // share key = 'password', // S3 Keys, Swift Keys, Authentication warning = 'warning--alt--filled', // Notification warning @@ -175,7 +176,9 @@ export const ICON_TYPE = { rightArrow: 'caret--right', locked: 'locked', cloudMonitoring: 'cloud--monitoring', - trash: 'trash-can' + trash: 'trash-can', + replicate: 'replicate', + share: 'share' } as const; export const EMPTY_STATE_IMAGE = { diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss index ac3a20cea99..ef04f9b6fa7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss @@ -126,6 +126,10 @@ margin-right: layout.$spacing-05; } +.cds-mr-12 { + margin-right: layout.$spacing-12; +} + .cds-pt-6 { padding-top: layout.$spacing-06; }