From eb3658a425a86b5826c49de51d934d86df6d00ba Mon Sep 17 00:00:00 2001 From: Pedro Gonzalez Gomez Date: Wed, 28 Jan 2026 22:23:25 +0100 Subject: [PATCH] mgr/dashboard: add CephFS Mirroring enablement page Fixes: https://tracker.ceph.com/issues/74633 Signed-off-by: Pedro Gonzalez Gomez --- .../frontend/src/app/app-routing.module.ts | 28 +++++++- .../cephfs-mirroring-error.component.html | 33 ++++++++++ .../cephfs-mirroring-error.component.scss | 16 +++++ .../cephfs-mirroring-error.component.spec.ts | 61 ++++++++++++++++++ .../cephfs-mirroring-error.component.ts | 25 +++++++ .../cephfs-mirroring-list.component.html | 22 ++----- .../src/app/ceph/cephfs/cephfs.module.ts | 14 ++-- .../workbench-layout.component.html | 6 ++ .../workbench-layout.component.ts | 26 +++++++- .../src/app/shared/constants/app.constants.ts | 5 ++ .../frontend/src/assets/empty-state.png | Bin 0 -> 4407 bytes .../frontend/src/styles/_carbon-defaults.scss | 4 ++ 12 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/assets/empty-state.png 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 32d910a031e..9360cbdb302 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 @@ -35,7 +35,11 @@ import { BlankLayoutComponent } from './core/layouts/blank-layout/blank-layout.c import { LoginLayoutComponent } from './core/layouts/login-layout/login-layout.component'; import { WorkbenchLayoutComponent } from './core/layouts/workbench-layout/workbench-layout.component'; import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component'; -import { ActionLabels, URLVerbs } from './shared/constants/app.constants'; +import { + ActionLabels, + CEPHFS_MIRRORING_PAGE_HEADER, + URLVerbs +} from './shared/constants/app.constants'; import { CrudFormComponent } from './shared/forms/crud-form/crud-form.component'; import { CRUDTableComponent } from './shared/datatable/crud-table/crud-table.component'; import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs'; @@ -62,6 +66,7 @@ import { MultiClusterFormComponent } from './ceph/cluster/multi-cluster/multi-cl 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'; @Injectable() export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver { @@ -107,6 +112,15 @@ const routes: Routes = [ children: [ { path: 'overview', component: DashboardComponent }, { path: 'error', component: ErrorComponent }, + { + path: 'cephfs/mirroring/error', + component: CephfsMirroringErrorComponent, + data: { + breadcrumbs: 'File/Mirroring', + pageHeader: CEPHFS_MIRRORING_PAGE_HEADER + } + }, + // Cluster { path: 'notifications', @@ -427,8 +441,18 @@ const routes: Routes = [ }, { path: 'mirroring', + canActivate: [ModuleStatusGuardService], component: CephfsMirroringListComponent, - data: { breadcrumbs: 'File/Mirroring' } + data: { + moduleStatusGuardConfig: { + uiApiPath: 'cephfs/mirror', + redirectTo: 'cephfs/mirroring/error', + module_name: 'mirroring', + navigate_to: 'File/Mirroring' + }, + breadcrumbs: 'File/Mirroring', + pageHeader: CEPHFS_MIRRORING_PAGE_HEADER + } }, { path: `mirroring/${URLVerbs.CREATE}`, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.html new file mode 100644 index 00000000000..5141bb2460d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.html @@ -0,0 +1,33 @@ + + +

Enable CephFS Mirroring

+

Turn on CephFS Mirroring to start creating mirror links and synchronizing data across clusters. After enabling, you can add mirror links.

+ +
+
+
+
+ no-mirror-links +
+
+
+
+

No CephFS mirror links available

+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.scss new file mode 100644 index 00000000000..0f95137319a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.scss @@ -0,0 +1,16 @@ +// Stack title above description when notification is full-width (Carbon lays them +// in a row by default). +cds-inline-notification.full-width { + max-inline-size: 100%; + + [class*='inline-notification__text-wrapper'] { + display: flex; + flex-direction: column; + align-items: flex-start; + } + + [class*='inline-notification__title'], + [class*='inline-notification__subtitle'] { + display: block; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.spec.ts new file mode 100644 index 00000000000..375207376c7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.spec.ts @@ -0,0 +1,61 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { of } from 'rxjs'; + +import { CephfsMirroringErrorComponent } from './cephfs-mirroring-error.component'; +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('CephfsMirroringErrorComponent', () => { + let component: CephfsMirroringErrorComponent; + let fixture: ComponentFixture; + + const routerMock = { + events: of({}), + onSameUrlNavigation: 'reload' as const, + navigate: jest.fn() + }; + + const mgrModuleServiceMock = { + updateModuleState: jest.fn(), + updateCompleted$: { subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) } + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + declarations: [CephfsMirroringErrorComponent], + imports: [SharedModule, RouterTestingModule], + providers: [ + { provide: Router, useValue: routerMock }, + { provide: MgrModuleService, useValue: mgrModuleServiceMock } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(CephfsMirroringErrorComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should call mgrModuleService.updateModuleState when enableModule is called', () => { + fixture.detectChanges(); + component.enableModule(); + expect(mgrModuleServiceMock.updateModuleState).toHaveBeenCalledWith( + 'mirroring', + false, + null, + 'cephfs/mirroring', + expect.any(String), + false, + expect.any(String) + ); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.ts new file mode 100644 index 00000000000..c21b1d2651c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.ts @@ -0,0 +1,25 @@ +import { Component, ViewEncapsulation } from '@angular/core'; +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; + +@Component({ + selector: 'cd-cephfs-mirroring-error', + templateUrl: './cephfs-mirroring-error.component.html', + styleUrls: ['./cephfs-mirroring-error.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class CephfsMirroringErrorComponent { + constructor(private mgrModuleService: MgrModuleService) {} + + enableModule(): void { + this.mgrModuleService.updateModuleState( + 'mirroring', + false, + null, + 'cephfs/mirroring', + $localize`CephFS Mirroring module enabled`, + false, + $localize`Enabling CephFS Mirroring. Reconnecting, please wait ...` + ); + } +} 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 54f6d9823e3..2ba0db47e33 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,12 +1,4 @@ - - - - - +@if (daemonStatus$ | async; as daemonStatus) { - - + + - +} 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 47ec4f9b355..b6461be67ff 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 @@ -31,6 +31,8 @@ import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapsh import { CephfsSnapshotscheduleFormComponent } from './cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component'; import { CephfsMountDetailsComponent } from './cephfs-mount-details/cephfs-mount-details.component'; 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 { ButtonModule, CheckboxModule, @@ -47,16 +49,17 @@ import { PlaceholderModule, SelectModule, TimePickerModule, + TilesModule, TreeviewModule, TabsModule, - RadioModule + RadioModule, + NotificationModule } from 'carbon-components-angular'; import AddIcon from '@carbon/icons/es/add/32'; 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 { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mirroring-list.component'; import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard/cephfs-mirroring-wizard.component'; import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector/cephfs-filesystem-selector.component'; @@ -91,7 +94,9 @@ import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector/ IconModule, BaseChartDirective, TabsModule, - RadioModule + RadioModule, + TilesModule, + NotificationModule ], declarations: [ CephfsDetailComponent, @@ -114,7 +119,8 @@ import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector/ CephfsAuthModalComponent, CephfsMirroringListComponent, CephfsMirroringWizardComponent, - CephfsFilesystemSelectorComponent + CephfsFilesystemSelectorComponent, + CephfsMirroringErrorComponent ], providers: [provideCharts(withDefaultRegisterables())] }) 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 dc906d4ee69..458335c77cf 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 @@ -15,6 +15,12 @@ + @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 a162e5f067d..3cc7faf0eb6 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 @@ -1,7 +1,8 @@ import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; +import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; import { Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; import { MultiClusterService } from '~/app/shared/api/multi-cluster.service'; import { Permissions } from '~/app/shared/models/permissions'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; @@ -24,6 +25,9 @@ export class WorkbenchLayoutComponent implements OnInit, OnDestroy { notifications: string[] = []; private subs = new Subscription(); permissions: Permissions; + pageHeaderTitle: string | null = null; + pageHeaderDescription: string | null = null; + @HostBinding('class') get class(): string { return 'top-notification-' + this.notifications.length; } @@ -65,7 +69,27 @@ export class WorkbenchLayoutComponent implements OnInit, OnDestroy { }) ); this.faviconService.init(); + + this.updatePageHeaderFromRoute(); + this.subs.add( + this.router.events + .pipe(filter((e) => e instanceof NavigationEnd)) + .subscribe(() => this.updatePageHeaderFromRoute()) + ); } + + private updatePageHeaderFromRoute(): void { + let route: ActivatedRouteSnapshot | null = this.router.routerState.snapshot.root; + while (route?.firstChild) { + route = route.firstChild; + } + const pageHeader = route?.routeConfig?.data?.['pageHeader'] as + | { title?: string; description?: string } + | undefined; + this.pageHeaderTitle = pageHeader?.title ?? null; + this.pageHeaderDescription = pageHeader?.description ?? null; + } + showTopNotification(name: string, isDisplayed: boolean) { if (isDisplayed) { if (!this.notifications.includes(name)) { 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 7de826aa2e3..e7b16fb3b1f 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 @@ -386,3 +386,8 @@ export const SSL_CIPHERS = [ 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.` +}; diff --git a/src/pybind/mgr/dashboard/frontend/src/assets/empty-state.png b/src/pybind/mgr/dashboard/frontend/src/assets/empty-state.png new file mode 100644 index 0000000000000000000000000000000000000000..38ed1e0b6aee9af6796c3167587cbe9fff24c361 GIT binary patch literal 4407 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
nmQmI15gC~$SF@RTTxAb z11IXJEP%lZA>c#-R4h|lo*c`t4BU$#BcK921QJf%C(oTb*V0h!Uea*@z0`QJp_^Z@ zN2fFpiN@NxAOOveaNPCupHe__?(EZ_8<#F!LTb_$o@*xa^-pN;a>{Y@g%@6!@To%( zSQye82!O&~z|&<;+1X)Q1PZKBT$n9!`r^flR~Wpu1&Mpfd|6;57|k#*^-46>xnatM z01Q@$^VAB(WtkpkI9OcgVO&Reu9++dfFheVZR%zadv*S(-CIXx4J^d}j1^NZQ=R6y zX0o6FdZ}@s>18YHZj{>O0OUY)>+7$-o)?~LCW`?;k=j#FJ%w=EB|vJ>#Ep4B{q48k z0=Ty3b5&(A{U`IgY*CX>Jn_U0PTgj7u<-w*W@h#3)#D5v!tJ5}53-mnFcMCCFz?ul x0K?$jI)3~(W{elqUH=ya9EJ>&IL{|U+002ovPDHLkV1g)zT