From 8a41bafcf18bdae7244165168e8857d4e34a7bcf Mon Sep 17 00:00:00 2001 From: Dnyaneshwari Talwekar Date: Wed, 8 Jul 2026 10:39:55 +0530 Subject: [PATCH] mgr/dashboard: Cephfs Mirroring - Enable Snapshot Schedule Signed-off-by: Dnyaneshwari Talwekar Fixes: https://tracker.ceph.com/issues/78014 --- .../cephfs-mirroring-error.component.spec.ts | 7 +- .../cephfs-mirroring-error.component.ts | 6 +- ...hfs-mirroring-fs-mirror-paths.component.ts | 30 ++-- .../cephfs-snapshotschedule-form.component.ts | 3 +- .../app/shared/api/mgr-module.service.spec.ts | 41 +++++- .../src/app/shared/api/mgr-module.service.ts | 133 ++++++++++-------- .../src/app/shared/models/permission.spec.ts | 3 + 7 files changed, 142 insertions(+), 81 deletions(-) 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 index 375207376c7..0ed538b9983 100644 --- 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 @@ -19,8 +19,7 @@ describe('CephfsMirroringErrorComponent', () => { }; const mgrModuleServiceMock = { - updateModuleState: jest.fn(), - updateCompleted$: { subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) } + updateModuleState: jest.fn() }; beforeEach(async () => { @@ -45,11 +44,11 @@ describe('CephfsMirroringErrorComponent', () => { expect(component).toBeTruthy(); }); - it('should call mgrModuleService.updateModuleState when enableModule is called', () => { + it('should enable mirroring and snap_schedule modules when enableModule is called', () => { fixture.detectChanges(); component.enableModule(); expect(mgrModuleServiceMock.updateModuleState).toHaveBeenCalledWith( - 'mirroring', + ['mirroring', 'snap_schedule'], false, null, 'cephfs/mirroring', 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 index c21b1d2651c..b8ce8b289d5 100644 --- 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 @@ -13,13 +13,13 @@ export class CephfsMirroringErrorComponent { enableModule(): void { this.mgrModuleService.updateModuleState( - 'mirroring', + ['mirroring', 'snap_schedule'], false, null, 'cephfs/mirroring', - $localize`CephFS Mirroring module enabled`, + $localize`CephFS Mirroring and Snapshot Schedule modules enabled`, false, - $localize`Enabling CephFS Mirroring. Reconnecting, please wait ...` + $localize`Enabling CephFS Mirroring and Snapshot schedule modules. Reconnecting, please wait ...` ); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts index 63cbe6aab0c..f7c4cd660e2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts @@ -68,6 +68,7 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy currentSyncSnapshotTpl!: TemplateRef; private cephfsService = inject(CephfsService); + private snapshotScheduleService = inject(CephfsSnapshotScheduleService); private route = inject(ActivatedRoute); private formatterService = inject(FormatterService); private authStorageService = inject(AuthStorageService); @@ -160,21 +161,20 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy itemNames: [path], actionDescription: 'remove', submitActionObservable: () => - this.taskWrapper - .wrapTaskAroundCall({ - task: new FinishedTask('cephfs/mirroring/path/remove', { - fsName: this.fsName, - path - }), - call: this.cephfsService.removeMirrorDirectory(this.fsName, path).pipe( - tap(() => { - if (this.selectedPath?.path === path) { - this.closeSidePanel(); - } - this.loadMirrorPaths(); - }) - ) - }) + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask('cephfs/mirroring/path/remove', { + fsName: this.fsName, + path + }), + call: this.cephfsService.removeMirrorDirectory(this.fsName, path).pipe( + tap(() => { + if (this.selectedPath?.path === path) { + this.closeSidePanel(); + } + this.loadMirrorPaths(); + }) + ) + }) }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts index 9e2ee920cd4..f55147ca845 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts @@ -55,8 +55,7 @@ const DEBOUNCE_TIMER = 300; }) export class CephfsSnapshotscheduleFormComponent extends CdForm - implements OnInit, OnChanges, TearsheetStep -{ + implements OnInit, OnChanges, TearsheetStep { @Input() embedded = false; @Input() hideDirectory = false; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts index 00ed2fe4c39..f1d077b9a19 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts @@ -1,5 +1,12 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + flush, + TestBed, + tick +} from '@angular/core/testing'; import { of as observableOf, throwError as observableThrowError } from 'rxjs'; import { configureTestBed } from '~/testing/unit-test-helper'; @@ -146,6 +153,38 @@ describe('MgrModuleService', () => { expect(blockUIService.stop).toHaveBeenCalled(); })); + it('should enable multiple modules sequentially', fakeAsync(() => { + const summaryService = TestBed.inject(SummaryService); + spyOn(service, 'enable').and.returnValues( + observableThrowError('mirroring reconnect'), + observableOf(null) + ); + spyOn(service, 'list').and.returnValue(observableOf([])); + spyOn(notificationService, 'show'); + spyOn(service.updateCompleted$, 'next'); + + service.updateModuleState( + ['mirroring', 'snap_schedule'], + false, + null, + '', + 'Enabled mirroring modules' + ); + tick(service.REFRESH_INTERVAL); + flush(); + + expect(service.enable).toHaveBeenCalledWith('mirroring', false); + expect(service.enable).toHaveBeenCalledWith('snap_schedule', false); + expect(service.list).toHaveBeenCalledTimes(1); + expect(notificationService.show).toHaveBeenCalledWith( + jasmine.any(Number), + jasmine.any(String) + ); + expect(service.updateCompleted$.next).toHaveBeenCalled(); + expect(summaryService.startPolling).toHaveBeenCalled(); + discardPeriodicTasks(); + })); + it('should not disable module without selecting one', () => { expect(component.getTableActionDisabledDesc()).toBeTruthy(); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts index 53a508b01f3..239d669d085 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts @@ -2,13 +2,13 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { BlockUIService } from 'ng-block-ui'; -import { Observable, Subject, timer } from 'rxjs'; +import { from, Observable, Subject, timer } from 'rxjs'; import { NotificationService } from '../services/notification.service'; import { TableComponent } from '../datatable/table/table.component'; import { Router } from '@angular/router'; import { MgrModuleInfo } from '../models/mgr-modules.interface'; import { NotificationType } from '../enum/notification-type.enum'; -import { delay, retryWhen, switchMap, tap } from 'rxjs/operators'; +import { catchError, concatMap, delay, map, retryWhen, switchMap, tap } from 'rxjs/operators'; import { SummaryService } from '../services/summary.service'; const GLOBAL = 'global'; @@ -89,9 +89,10 @@ export class MgrModuleService { /** * Update the Ceph Mgr module state to enabled or disabled. + * @param modules One module name or a list of module names to enable/disable sequentially. */ updateModuleState( - module: string, + modules: string | string[], enabled: boolean = false, table: TableComponent = null, navigateTo: string = '', @@ -100,61 +101,81 @@ export class MgrModuleService { reconnectingMessage: string = $localize`Reconnecting, please wait ...`, force: boolean = false ): void { + const moduleList = Array.isArray(modules) ? modules : [modules]; + + from(moduleList) + .pipe( + concatMap((module) => + this.toggleModuleWithReconnect(module, enabled, force, reconnectingMessage) + ) + ) + .subscribe({ + complete: () => { + this.completeModuleStateUpdate(table, navigateTo, notificationText, navigateByUrl); + } + }); + } + + private toggleModuleWithReconnect( + module: string, + enabled: boolean, + force: boolean, + reconnectingMessage: string + ): Observable { const moduleToggle$ = enabled ? this.disable(module) : this.enable(module, force); - moduleToggle$.subscribe({ - next: () => { - // Module toggle succeeded - this.updateCompleted$.next(); - }, - error: () => { - // Module toggle failed, trigger reconnect flow - this.notificationService.suspendToasties(true); - this.blockUI.start(GLOBAL, reconnectingMessage); - - timer(this.REFRESH_INTERVAL) - .pipe( - switchMap(() => this.list()), - retryWhen((errors) => - errors.pipe( - tap(() => { - // Keep retrying until list() succeeds - }), - delay(this.REFRESH_INTERVAL) - ) - ) - ) - .subscribe({ - next: () => { - // Reconnection successful - this.notificationService.suspendToasties(false); - this.blockUI.stop(GLOBAL); - - if (table) { - table.refreshBtn(); - } - - if (notificationText) { - this.notificationService.show( - NotificationType.success, - $localize`${notificationText}` - ); - } - - if (navigateTo) { - const navigate = () => this.router.navigate([navigateTo]); - if (navigateByUrl) { - this.router.navigateByUrl('/', { skipLocationChange: true }).then(navigate); - } else { - navigate(); - } - } - - this.updateCompleted$.next(); - this.summaryService.startPolling(); - } - }); + return moduleToggle$.pipe( + map(() => undefined), + catchError(() => this.reconnectAfterModuleToggle(reconnectingMessage)) + ); + } + + private reconnectAfterModuleToggle(reconnectingMessage: string): Observable { + this.notificationService.suspendToasties(true); + this.blockUI.start(GLOBAL, reconnectingMessage); + + return timer(this.REFRESH_INTERVAL).pipe( + switchMap(() => this.list()), + retryWhen((errors) => + errors.pipe( + tap(() => { + // Keep retrying until list() succeeds + }), + delay(this.REFRESH_INTERVAL) + ) + ), + tap(() => { + this.notificationService.suspendToasties(false); + this.blockUI.stop(GLOBAL); + }), + map(() => undefined) + ); + } + + private completeModuleStateUpdate( + table: TableComponent, + navigateTo: string, + notificationText?: string, + navigateByUrl?: boolean + ): void { + if (table) { + table.refreshBtn(); + } + + if (notificationText) { + this.notificationService.show(NotificationType.success, $localize`${notificationText}`); + } + + if (navigateTo) { + const navigate = () => this.router.navigate([navigateTo]); + if (navigateByUrl) { + this.router.navigateByUrl('/', { skipLocationChange: true }).then(navigate); + } else { + navigate(); } - }); + } + + this.updateCompleted$.next(); + this.summaryService.startPolling(); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts index d1010a3408a..3a86837057c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts @@ -4,6 +4,7 @@ describe('cd-notification classes', () => { it('should show empty permissions', () => { expect(new Permissions({})).toEqual({ cephfs: { create: false, delete: false, read: false, update: false }, + cephfsMirror: { create: false, delete: false, read: false, update: false }, configOpt: { create: false, delete: false, read: false, update: false }, grafana: { create: false, delete: false, read: false, update: false }, hosts: { create: false, delete: false, read: false, update: false }, @@ -27,6 +28,7 @@ describe('cd-notification classes', () => { it('should show full permissions', () => { const fullyGranted = { cephfs: ['create', 'read', 'update', 'delete'], + 'cephfs-mirror': ['create', 'read', 'update', 'delete'], 'config-opt': ['create', 'read', 'update', 'delete'], grafana: ['create', 'read', 'update', 'delete'], hosts: ['create', 'read', 'update', 'delete'], @@ -46,6 +48,7 @@ describe('cd-notification classes', () => { }; expect(new Permissions(fullyGranted)).toEqual({ cephfs: { create: true, delete: true, read: true, update: true }, + cephfsMirror: { create: true, delete: true, read: true, update: true }, configOpt: { create: true, delete: true, read: true, update: true }, grafana: { create: true, delete: true, read: true, update: true }, hosts: { create: true, delete: true, read: true, update: true }, -- 2.47.3