]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Cephfs Mirroring - Enable Snapshot Schedule enable-schedule-module
authorDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Wed, 8 Jul 2026 05:09:55 +0000 (10:39 +0530)
committerDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Tue, 14 Jul 2026 10:10:47 +0000 (15:40 +0530)
Signed-off-by: Dnyaneshwari Talwekar <dtalweka@redhat.com>
Fixes: https://tracker.ceph.com/issues/78014
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts

index 375207376c76daccfeb8b8ed591b080a48cfa72e..0ed538b99837489faf883733bca72ca98563b6d1 100644 (file)
@@ -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',
index c21b1d2651c7b35b564d1812a3cb1919164ce627..b8ce8b289d5ffc8ad1838e4f37c8c30381f7b152 100644 (file)
@@ -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 ...`
     );
   }
 }
index 63cbe6aab0cd2fe8c493d0dadb8557316ab02cc9..f7c4cd660e221a36b35fc37c4a5992e16115c24e 100644 (file)
@@ -68,6 +68,7 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
   currentSyncSnapshotTpl!: TemplateRef<unknown>;
 
   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();
+            })
+          )
+        })
     });
   }
 
index 9e2ee920cd4ddd03b51085c7262c7d038bcdcb6c..f55147ca8451997e0ba5c6bf74284dbdf3ed5413 100644 (file)
@@ -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;
 
index 00ed2fe4c39917f34adabaebf23cd37f2971c48b..f1d077b9a1952152750548c5025229d00e357fec 100644 (file)
@@ -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();
     });
index 53a508b01f3b77746ef836382143c4d97ef756f2..239d669d085f2ad630f625783b8d22062c7c03b9 100644 (file)
@@ -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<void> {
     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<void> {
+    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();
   }
 }
index d1010a3408a15e7aca9936713d1e86d1fd92166e..3a86837057cda8f4ec9cf01c4567c199dd55c6a5 100644 (file)
@@ -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 },