]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: cephs add step 2 & 3 schedule form and summary in add mirror path...
authorPedro Gonzalez Gomez <pegonzal@ibm.com>
Wed, 1 Jul 2026 11:34:35 +0000 (13:34 +0200)
committerPedro Gonzalez Gomez <pegonzal@ibm.com>
Wed, 1 Jul 2026 12:48:44 +0000 (14:48 +0200)
Signed-off-by: Pedro Gonzalez Gomez <pegonzal@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-review-step/mirroring-review-step.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-review-step/mirroring-review-step.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts

index 5e6c207fdc69aba5ca85574d5cd271089e72abc8..012b94f5bfc18611d0a44d9275d87b39e7b1392d 100644 (file)
       [fsId]="fsId">
     </cd-mirroring-paths-step>
   </cd-tearsheet-step>
-  <cd-tearsheet-step></cd-tearsheet-step>
-  <cd-tearsheet-step></cd-tearsheet-step>
+  <cd-tearsheet-step>
+    <p class="cds--type-heading-03 cds-mb-3"
+       i18n>Snapshot schedule</p>
+    <p class="cds--type-body-compact-01 cds-mb-5"
+       i18n>Define when snapshots are taken for the mirrored path(s).</p>
+    <cd-cephfs-snapshotschedule-form
+      #scheduleStep
+      #tearsheetStep
+      [embedded]="true"
+      [hideDirectory]="true"
+      [fsNameInput]="fsName"
+      [fsIdInput]="fsId"
+      [schedulePath]="schedulePath">
+    </cd-cephfs-snapshotschedule-form>
+  </cd-tearsheet-step>
+  <cd-tearsheet-step>
+    <cd-mirroring-review-step
+      #tearsheetStep
+      [fsName]="fsName"
+      [pathsStep]="pathsStep"
+      [scheduleStep]="scheduleStep">
+    </cd-mirroring-review-step>
+  </cd-tearsheet-step>
 </cd-tearsheet>
index b1d0ae45c3728af0524d0c3e10e7f617848e6b9b..c86bfb0aef19b52083d90078a0f333ae5db1fa55 100644 (file)
@@ -6,8 +6,10 @@ import { observeOn } from 'rxjs/operators';
 
 import { CephfsAddMirroringPathComponent } from './cephfs-add-mirroring-path.component';
 import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { NotificationService } from '~/app/shared/services/notification.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 
 describe('CephfsAddMirroringPathComponent', () => {
   let component: CephfsAddMirroringPathComponent;
@@ -18,6 +20,14 @@ describe('CephfsAddMirroringPathComponent', () => {
     addMirrorDirectory: jest.fn()
   };
 
+  const snapshotScheduleServiceMock = {
+    create: jest.fn()
+  };
+
+  const taskWrapperMock = {
+    wrapTaskAroundCall: jest.fn(({ call }) => call)
+  };
+
   const notificationServiceMock = {
     show: jest.fn()
   };
@@ -42,6 +52,8 @@ describe('CephfsAddMirroringPathComponent', () => {
           useValue: { navigate: routerNavigateSpy }
         },
         { provide: CephfsService, useValue: cephfsServiceMock },
+        { provide: CephfsSnapshotScheduleService, useValue: snapshotScheduleServiceMock },
+        { provide: TaskWrapperService, useValue: taskWrapperMock },
         { provide: NotificationService, useValue: notificationServiceMock }
       ],
       schemas: [NO_ERRORS_SCHEMA]
@@ -74,7 +86,7 @@ describe('CephfsAddMirroringPathComponent', () => {
   });
 
   it('should skip API calls when the paths step form is invalid', () => {
-    const refreshTrackedPaths = jest.fn();
+    const refreshTrackedPaths = jest.fn(() => of(undefined));
     component.pathsStep = {
       formGroup: {
         markAllAsTouched: jest.fn(),
@@ -87,8 +99,7 @@ describe('CephfsAddMirroringPathComponent', () => {
 
     component.onSubmit();
 
-    expect(component.pathsStep.formGroup.markAllAsTouched).toHaveBeenCalled();
-    expect(refreshTrackedPaths).not.toHaveBeenCalled();
+    expect(refreshTrackedPaths).toHaveBeenCalled();
     expect(notificationServiceMock.show).not.toHaveBeenCalled();
     expect(cephfsServiceMock.addMirrorDirectory).not.toHaveBeenCalled();
     expect(component.isSubmitLoading).toBe(false);
@@ -107,6 +118,10 @@ describe('CephfsAddMirroringPathComponent', () => {
       addTrackedPath: jest.fn(),
       ...overrides
     } as any;
+    component.scheduleStep = {
+      buildCreatePayload: jest.fn((path: string) => ({ path, fs: 'testfs' }))
+    } as any;
+    snapshotScheduleServiceMock.create.mockReturnValue(of({}));
   }
 
   it('should add mirror directories and close modal on success', fakeAsync(() => {
@@ -234,6 +249,8 @@ describe('CephfsAddMirroringPathComponent', () => {
           useValue: { navigate: routerNavigateSpy }
         },
         { provide: CephfsService, useValue: cephfsServiceMock },
+        { provide: CephfsSnapshotScheduleService, useValue: snapshotScheduleServiceMock },
+        { provide: TaskWrapperService, useValue: taskWrapperMock },
         { provide: NotificationService, useValue: notificationServiceMock }
       ],
       schemas: [NO_ERRORS_SCHEMA]
index e1b99e46bf23ccd594714e24ad3513d77afa6970..d119693a900b5536fcabed617ad8185e6db49a41 100644 (file)
@@ -6,11 +6,16 @@ import { from, of } from 'rxjs';
 import { catchError, concatMap, finalize, map, switchMap, tap, toArray } from 'rxjs/operators';
 
 import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service';
+import { URLVerbs } from '~/app/shared/constants/app.constants';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { FinishedTask } from '~/app/shared/models/finished-task';
 import { NotificationService } from '~/app/shared/services/notification.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { MirroringPathUtils } from './mirroring-path-utils';
 import { PathSubmitFailure, PathSubmitOutput } from './mirroring-path.model';
 import { MirroringPathsStepComponent } from './mirroring-paths-step/mirroring-paths-step.component';
+import { CephfsSnapshotscheduleFormComponent } from '../cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component';
 
 import { CEPHFS_MIRRORING_URL } from '~/app/shared/constants/cephfs.constant';
 
@@ -22,10 +27,13 @@ import { CEPHFS_MIRRORING_URL } from '~/app/shared/constants/cephfs.constant';
 })
 export class CephfsAddMirroringPathComponent implements OnInit {
   @ViewChild('pathsStep') pathsStep!: MirroringPathsStepComponent;
+  @ViewChild('scheduleStep') scheduleStep?: CephfsSnapshotscheduleFormComponent;
 
   private route = inject(ActivatedRoute);
   private router = inject(Router);
   private cephfsService = inject(CephfsService);
+  private snapshotScheduleService = inject(CephfsSnapshotScheduleService);
+  private taskWrapper = inject(TaskWrapperService);
   private notificationService = inject(NotificationService);
   private destroyRef = inject(DestroyRef);
 
@@ -40,6 +48,10 @@ export class CephfsAddMirroringPathComponent implements OnInit {
   ];
   isSubmitLoading = false;
 
+  get schedulePath(): string {
+    return this.pathsStep?.getSubmitPaths()?.toAdd?.[0] ?? '';
+  }
+
   ngOnInit(): void {
     this.fsId = Number(this.route.snapshot.paramMap.get('fsId'));
     const fsName = this.route.snapshot.paramMap.get('fsName') ?? '';
@@ -56,12 +68,6 @@ export class CephfsAddMirroringPathComponent implements OnInit {
       return;
     }
 
-    pathsStep.formGroup.markAllAsTouched();
-    pathsStep.formGroup.updateValueAndValidity();
-    if (pathsStep.formGroup.invalid) {
-      return;
-    }
-
     this.isSubmitLoading = true;
 
     pathsStep
@@ -77,7 +83,12 @@ export class CephfsAddMirroringPathComponent implements OnInit {
               skippedByServer: [],
               succeeded: []
             });
-            return of([] as (string | null)[]);
+            return of({
+              failed: [],
+              alreadyMirrored,
+              skippedByServer: [],
+              succeeded: []
+            });
           }
 
           const skippedByServer: string[] = [];
@@ -109,15 +120,13 @@ export class CephfsAddMirroringPathComponent implements OnInit {
               );
             }),
             toArray(),
-            tap((results) => {
+            switchMap((results) => {
               const succeeded = results.filter((path): path is string => !!path);
-              this.showSubmitSummary({
-                failed,
-                alreadyMirrored,
-                skippedByServer,
-                succeeded
-              });
-            })
+              return this.createSnapshotSchedules(succeeded).pipe(
+                map(() => ({ failed, alreadyMirrored, skippedByServer, succeeded }))
+              );
+            }),
+            tap((outcome) => this.showSubmitSummary(outcome))
           );
         }),
         finalize(() => {
@@ -125,14 +134,45 @@ export class CephfsAddMirroringPathComponent implements OnInit {
         }),
         takeUntilDestroyed(this.destroyRef)
       )
-      .subscribe((results) => {
-        const succeeded = results.filter((path): path is string => !!path);
-        if (succeeded.length) {
+      .subscribe((outcome) => {
+        if (outcome?.succeeded?.length) {
           this.closeTearsheet(true);
         }
       });
   }
 
+  private createSnapshotSchedules(paths: string[]) {
+    if (!this.scheduleStep || !paths.length) {
+      return of(undefined);
+    }
+
+    return from(paths).pipe(
+      concatMap((path) =>
+        this.taskWrapper
+          .wrapTaskAroundCall({
+            task: new FinishedTask('cephfs/snapshot/schedule/' + URLVerbs.CREATE, { path }),
+            call: this.snapshotScheduleService.create(this.scheduleStep.buildCreatePayload(path))
+          })
+          .pipe(
+            catchError((error) => {
+              const detail =
+                error?.error?.detail ||
+                error?.message ||
+                $localize`Failed to create snapshot schedule for '${path}'`;
+              this.notificationService.show(
+                NotificationType.error,
+                $localize`Failed to create snapshot schedule`,
+                detail
+              );
+              return of(undefined);
+            })
+          )
+      ),
+      toArray(),
+      map(() => undefined)
+    );
+  }
+
   onCancel(): void {
     this.closeTearsheet(false);
   }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-review-step/mirroring-review-step.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-review-step/mirroring-review-step.component.html
new file mode 100644 (file)
index 0000000..3319d3c
--- /dev/null
@@ -0,0 +1,31 @@
+<p class="cds--type-heading-03 cds-mb-3"
+   i18n>Review</p>
+<p class="cds--type-body-compact-01 cds-mb-5"
+   i18n>Confirm the paths and snapshot schedule before adding the mirror path(s).</p>
+
+<div cdsStack="vertical"
+     [gap]="5">
+  <div>
+    <div class="cds--type-label-01 cds-mb-2"
+         i18n>Filesystem</div>
+    <div class="cds--type-body-01">{{ fsName }}</div>
+  </div>
+
+  <div>
+    <div class="cds--type-label-01 cds-mb-2"
+         i18n>Paths</div>
+    @if (pathsToAdd.length) {
+      @for (path of pathsToAdd; track path) {
+        <div class="cds--type-body-01">{{ path }}</div>
+      }
+    } @else {
+      <div class="cds--type-body-01">—</div>
+    }
+  </div>
+
+  <div>
+    <div class="cds--type-label-01 cds-mb-2"
+         i18n>Snapshot schedule</div>
+    <div class="cds--type-body-01">{{ scheduleSummary || '—' }}</div>
+  </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-review-step/mirroring-review-step.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-review-step/mirroring-review-step.component.ts
new file mode 100644 (file)
index 0000000..0028f6a
--- /dev/null
@@ -0,0 +1,31 @@
+import { Component, Input, OnInit } from '@angular/core';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
+import { CephfsSnapshotscheduleFormComponent } from '../../cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component';
+import { MirroringPathsStepComponent } from '../mirroring-paths-step/mirroring-paths-step.component';
+
+@Component({
+  selector: 'cd-mirroring-review-step',
+  templateUrl: './mirroring-review-step.component.html',
+  standalone: false
+})
+export class MirroringReviewStepComponent implements OnInit, TearsheetStep {
+  @Input() fsName = '';
+  @Input() pathsStep?: MirroringPathsStepComponent;
+  @Input() scheduleStep?: CephfsSnapshotscheduleFormComponent;
+
+  formGroup!: CdFormGroup;
+
+  ngOnInit(): void {
+    this.formGroup = new CdFormGroup({});
+  }
+
+  get pathsToAdd(): string[] {
+    return this.pathsStep?.getSubmitPaths()?.toAdd ?? [];
+  }
+
+  get scheduleSummary(): string {
+    return this.scheduleStep?.getScheduleSummary() ?? '';
+  }
+}
index f1a2dcb6b4b746c726aa61fec7f349f0c3996268..b8af71bef9087a6e325558c2dc3764ad5ec9997c 100644 (file)
-<cds-modal size="lg"
-           [open]="open"
-           [hasScrollingContent]="true"
-           (overlaySelected)="closeModal()">
-  <cds-modal-header (closeSelect)="closeModal()">
-    <h3 cdsModalHeaderHeading
-        i18n>
-    {{ action | titlecase }} {{ resource | upperFirst }}
-    </h3>
-  </cds-modal-header>
+@if (!embedded) {
+  <cds-modal size="lg"
+             [open]="open"
+             [hasScrollingContent]="true"
+             (overlaySelected)="closeModal()">
+    <cds-modal-header (closeSelect)="closeModal()">
+      <h3 cdsModalHeaderHeading
+          i18n>
+        {{ action | titlecase }} {{ resource | upperFirst }}
+      </h3>
+    </cds-modal-header>
 
+    <ng-container *cdFormLoading="loading">
+      <div cdsModalContent>
+        <ng-container *ngTemplateOutlet="formBody"></ng-container>
+      </div>
+      <cd-form-button-panel (submitActionEvent)="submit()"
+                            [form]="snapScheduleForm"
+                            [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+                            [modalForm]="true"></cd-form-button-panel>
+    </ng-container>
+  </cds-modal>
+} @else {
   <ng-container *cdFormLoading="loading">
-    <div cdsModalContent>
-      <form name="snapScheduleForm"
-            #formDir="ngForm"
-            [formGroup]="snapScheduleForm"
-            novalidate>
-        <!-- Directory -->
-        <div class="form-item">
-          <cds-text-label for="directory"
-                          i18n
-                          cdRequiredField="Directory"
-                          [invalid]="snapScheduleForm.controls.directory.invalid && (snapScheduleForm.controls.directory.dirty)"
-                          [invalidText]="directoryError"
-                          [skeleton]="directoryStore.isLoading"
-                          modal-primary-focus>
-            <ng-container *ngIf="!directoryStore.isLoading">Directory (required)</ng-container>
-            <input cdsText
-                   type="text"
-                   formControlName="directory"
-                   name="directory"
-                   [ngbTypeahead]="search"
-                   [invalid]="snapScheduleForm.controls.directory.invalid && (snapScheduleForm.controls.directory.dirty)"
-                   [placeholder]="directoryStore.isLoading ? '' : 'Directory path'"
-                   [skeleton]="directoryStore.isLoading"/>
-          </cds-text-label>
-          <ng-template #directoryError>
-            <span class="invalid-feedback"
-                  *ngIf="snapScheduleForm.showError('directory', formDir, 'required')"
-                  i18n>This field is required.</span>
-            <span class="invalid-feedback"
-                  *ngIf="snapScheduleForm.showError('directory', formDir, 'notUnique')"
-                  i18n>A snapshot schedule for this path already exists.</span>
-          </ng-template>
-        </div>
+    <ng-container *ngTemplateOutlet="formBody"></ng-container>
+  </ng-container>
+}
 
-        <!--Start date -->
-        <cd-date-time-picker
-          name="Start Date"
-          helperText="The time zone is assumed to be UTC"
-          [control]="snapScheduleForm.get('startDate')"
-          [disabled]="isEdit"></cd-date-time-picker>
+<ng-template #formBody>
+  <form name="snapScheduleForm"
+        #formDir="ngForm"
+        [formGroup]="snapScheduleForm"
+        novalidate>
+    @if (!hideDirectory) {
+      <!-- Directory -->
+      <div class="form-item">
+        <cds-text-label for="directory"
+                        i18n
+                        cdRequiredField="Directory"
+                        [invalid]="snapScheduleForm.controls.directory.invalid && (snapScheduleForm.controls.directory.dirty)"
+                        [invalidText]="directoryError"
+                        [skeleton]="directoryStore.isLoading"
+                        modal-primary-focus>
+          <ng-container *ngIf="!directoryStore.isLoading">Directory (required)</ng-container>
+          <input cdsText
+                 type="text"
+                 formControlName="directory"
+                 name="directory"
+                 [ngbTypeahead]="search"
+                 [invalid]="snapScheduleForm.controls.directory.invalid && (snapScheduleForm.controls.directory.dirty)"
+                 [placeholder]="directoryStore.isLoading ? '' : 'Directory path'"
+                 [skeleton]="directoryStore.isLoading"/>
+        </cds-text-label>
+        <ng-template #directoryError>
           <span class="invalid-feedback"
-                *ngIf="snapScheduleForm.showError('startDate', formDir, 'required')"
+                *ngIf="snapScheduleForm.showError('directory', formDir, 'required')"
                 i18n>This field is required.</span>
+          <span class="invalid-feedback"
+                *ngIf="snapScheduleForm.showError('directory', formDir, 'notUnique')"
+                i18n>A snapshot schedule for this path already exists.</span>
+        </ng-template>
+      </div>
+    }
 
-        <!-- Repeat interval -->
-        <div class="form-item form-item-append"
-             cdsRow>
-          <div cdsCol>
-            <cds-number [id]="'repeatInterval'"
-                        [name]="'repeatInterval'"
-                        [formControlName]="'repeatInterval'"
-                        [label]="'Schedule'"
+    <!--Start date -->
+    <cd-date-time-picker
+      name="Start Date"
+      helperText="The time zone is assumed to be UTC"
+      [control]="snapScheduleForm.get('startDate')"
+      [disabled]="isEdit"></cd-date-time-picker>
+    <span class="invalid-feedback"
+          *ngIf="snapScheduleForm.showError('startDate', formDir, 'required')"
+          i18n>This field is required.</span>
+
+    <!-- Repeat interval -->
+    <div class="form-item form-item-append"
+         cdsRow>
+      <div cdsCol>
+        <cds-number [id]="'repeatInterval'"
+                    [name]="'repeatInterval'"
+                    [formControlName]="'repeatInterval'"
+                    [label]="'Schedule'"
+                    [min]="1"
+                    [invalid]="!snapScheduleForm.controls.repeatInterval.valid && (snapScheduleForm.controls.repeatInterval.dirty)"
+                    [invalidText]="repeatIntervalError"
+                    cdRequiredField="Schedule"></cds-number>
+      </div>
+      <div cdsCol>
+        <cds-select id="repeatFrequency"
+                    name="repeatFrequency"
+                    formControlName="repeatFrequency"
+                    label="Frequency"
+                    [invalid]="!snapScheduleForm.controls.repeatFrequency.valid && (snapScheduleForm.controls.repeatFrequency.dirty)"
+                    [invalidText]="repeatFrequencyError"
+                    *ngIf="repeatFrequencies">
+          <option *ngFor="let freq of repeatFrequencies"
+                  [value]="freq[1]">{{ freq[0] }}
+          </option>
+        </cds-select>
+        <ng-template #repeatFrequencyError>
+          <span class="invalid-feedback"
+                *ngIf="snapScheduleForm.showError('repeatFrequency', formDir, 'notUnique')"
+                i18n>This schedule already exists for the selected directory.</span>
+        </ng-template>
+        <ng-template #repeatIntervalError>
+          <span class="invalid-feedback"
+                *ngIf="snapScheduleForm.showError('repeatInterval', formDir, 'required')"
+                i18n>This field is required.</span>
+          <span class="invalid-feedback"
+                *ngIf="snapScheduleForm.showError('repeatInterval', formDir, 'min')"
+                i18n>Choose a value greater than 0.</span>
+        </ng-template>
+      </div>
+    </div>
+
+    <!-- Retention policies -->
+    <ng-container formArrayName="retentionPolicies"
+                  *ngFor="let retentionPolicy of retentionPolicies.controls; index as i">
+      <ng-container [formGroupName]="i">
+        <div cdsRow
+             class="form-item form-item-append">
+          <div cdsCol
+               [columnNumbers]="{lg: 8}">
+            <cds-number [id]="'retentionInterval' + i"
+                        [name]="'retentionInterval' + i"
+                        [formControlName]="'retentionInterval'"
+                        [label]="'Retention policy'"
                         [min]="1"
-                        [invalid]="!snapScheduleForm.controls.repeatInterval.valid && (snapScheduleForm.controls.repeatInterval.dirty)"
-                        [invalidText]="repeatIntervalError"
-                        cdRequiredField="Schedule"></cds-number>
+                        [invalid]="snapScheduleForm.controls['retentionPolicies'].controls[i].invalid && snapScheduleForm.controls['retentionPolicies'].dirty"
+                        [invalidText]="retentionPolicyError"></cds-number>
           </div>
-          <div cdsCol>
-            <cds-select id="repeatFrequency"
-                        name="repeatFrequency"
-                        formControlName="repeatFrequency"
+          <div cdsCol
+               [columnNumbers]="{lg: 7}">
+            <cds-select id="retentionFrequency"
+                        name="retentionFrequency"
+                        formControlName="retentionFrequency"
                         label="Frequency"
-                        [invalid]="!snapScheduleForm.controls.repeatFrequency.valid && (snapScheduleForm.controls.repeatFrequency.dirty)"
-                        [invalidText]="repeatFrequencyError"
-                        *ngIf="repeatFrequencies">
-              <option *ngFor="let freq of repeatFrequencies"
-                      [value]="freq[1]">{{ freq[0] }}
-              </option>
+                        *ngIf="retentionFrequencies">
+              <option *ngFor="let freq of retentionFrequencies"
+                      [value]="freq[1]">{{ freq[0] }}</option>
+
             </cds-select>
-            <ng-template #repeatFrequencyError>
-              <span class="invalid-feedback"
-                    *ngIf="snapScheduleForm.showError('repeatFrequency', formDir, 'notUnique')"
-                    i18n>This schedule already exists for the selected directory.</span>
-            </ng-template>
-            <ng-template #repeatIntervalError>
-              <span class="invalid-feedback"
-                    *ngIf="snapScheduleForm.showError('repeatInterval', formDir, 'required')"
-                    i18n>This field is required.</span>
-              <span class="invalid-feedback"
-                    *ngIf="snapScheduleForm.showError('repeatInterval', formDir, 'min')"
-                    i18n>Choose a value greater than 0.</span>
-            </ng-template>
+          </div>
+          <div cdsCol
+               [columnNumbers]="{lg: 1}"
+               class="item-action-btn">
+            <cds-icon-button kind="danger"
+                             size="sm"
+                             (click)="removeRetentionPolicy(i)">
+              <svg cdsIcon="trash-can"
+                   size="32"
+                   class="cds--btn__icon"></svg>
+            </cds-icon-button>
           </div>
         </div>
+        <ng-template #retentionPolicyError>
+          <span class="invalid-feedback"
+                *ngIf="snapScheduleForm.controls['retentionPolicies'].controls[i].invalid"
+                i18n>This retention policy already exists for the selected directory.</span>
+        </ng-template>
+      </ng-container>
+    </ng-container>
 
-        <!-- Retention policies -->
-        <ng-container formArrayName="retentionPolicies"
-                      *ngFor="let retentionPolicy of retentionPolicies.controls; index as i">
-          <ng-container [formGroupName]="i">
-            <div cdsRow
-                 class="form-item form-item-append">
-              <div cdsCol
-                   [columnNumbers]="{lg: 8}">
-                <cds-number [id]="'retentionInterval' + i"
-                            [name]="'retentionInterval' + i"
-                            [formControlName]="'retentionInterval'"
-                            [label]="'Retention policy'"
-                            [min]="1"
-                            [invalid]="snapScheduleForm.controls['retentionPolicies'].controls[i].invalid && snapScheduleForm.controls['retentionPolicies'].dirty"
-                            [invalidText]="retentionPolicyError"></cds-number>
-              </div>
-              <div cdsCol
-                   [columnNumbers]="{lg: 7}">
-                <cds-select id="retentionFrequency"
-                            name="retentionFrequency"
-                            formControlName="retentionFrequency"
-                            label="Frequency"
-                            *ngIf="retentionFrequencies">
-                  <option *ngFor="let freq of retentionFrequencies"
-                          [value]="freq[1]">{{ freq[0] }}</option>
-
-                </cds-select>
-              </div>
-              <div cdsCol
-                   [columnNumbers]="{lg: 1}"
-                   class="item-action-btn">
-                <cds-icon-button kind="danger"
-                                 size="sm"
-                                 (click)="removeRetentionPolicy(i)">
-                  <svg cdsIcon="trash-can"
-                       size="32"
-                       class="cds--btn__icon"></svg>
-                </cds-icon-button>
-              </div>
-            </div>
-            <ng-template #retentionPolicyError>
-              <span class="invalid-feedback"
-                    *ngIf="snapScheduleForm.controls['retentionPolicies'].controls[i].invalid"
-                    i18n>This retention policy already exists for the selected directory.</span>
-            </ng-template>
-          </ng-container>
-        </ng-container>
-
-        <div class="form-item">
-          <button cdsButton="tertiary"
-                  type="button"
-                  (click)="addRetentionPolicy()"
-                  i18n>
-            Add retention policy
-            <svg cdsIcon="add"
-                 size="32"
-                 class="cds--btn__icon"
-                 icon></svg>
-          </button>
-        </div>
-      </form>
+    <div class="form-item">
+      <button cdsButton="tertiary"
+              type="button"
+              (click)="addRetentionPolicy()"
+              i18n>
+        Add retention policy
+        <svg cdsIcon="add"
+             size="32"
+             class="cds--btn__icon"
+             icon></svg>
+      </button>
     </div>
-    <cd-form-button-panel (submitActionEvent)="submit()"
-                          [form]="snapScheduleForm"
-                          [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
-                          [modalForm]="true"></cd-form-button-panel>
-  </ng-container>
-</cds-modal>
+  </form>
+</ng-template>
index 91a6deb95d8600023f3fb1b0e8b8db1a069f849a..9e2ee920cd4ddd03b51085c7262c7d038bcdcb6c 100644 (file)
@@ -1,4 +1,13 @@
-import { ChangeDetectorRef, Component, Inject, OnInit, Optional } from '@angular/core';
+import {
+  ChangeDetectorRef,
+  Component,
+  Inject,
+  Input,
+  OnChanges,
+  OnInit,
+  Optional,
+  SimpleChanges
+} from '@angular/core';
 import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
 import { NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap';
 import { padStart, uniq } from 'lodash';
@@ -33,6 +42,7 @@ import {
   SnapshotScheduleFormValue
 } from '~/app/shared/models/snapshot-schedule';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
 
 const VALIDATON_TIMER = 300;
 const DEBOUNCE_TIMER = 300;
@@ -43,7 +53,37 @@ const DEBOUNCE_TIMER = 300;
   styleUrls: ['./cephfs-snapshotschedule-form.component.scss'],
   standalone: false
 })
-export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnInit {
+export class CephfsSnapshotscheduleFormComponent
+  extends CdForm
+  implements OnInit, OnChanges, TearsheetStep
+{
+  @Input() embedded = false;
+  @Input() hideDirectory = false;
+
+  @Input()
+  set fsIdInput(value: number) {
+    if (value) {
+      this.id = value;
+    }
+  }
+
+  @Input()
+  set fsNameInput(value: string) {
+    if (value) {
+      this.fsName = value;
+    }
+  }
+
+  @Input()
+  set schedulePath(value: string) {
+    if (value) {
+      this.path = value;
+      if (this.snapScheduleForm && this.hideDirectory) {
+        this.applyDirectoryPath(value);
+      }
+    }
+  }
+
   subvol!: string;
   group!: string;
   icons = Icons;
@@ -59,6 +99,10 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni
 
   snapScheduleForm!: CdFormGroup;
 
+  get formGroup(): CdFormGroup {
+    return this.snapScheduleForm;
+  }
+
   action!: string;
   resource!: string;
 
@@ -90,16 +134,27 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni
     }-${currentDatetime.getUTCDate()}`;
   }
 
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes.schedulePath && this.snapScheduleForm && this.hideDirectory && this.path) {
+      this.applyDirectoryPath(this.path);
+    }
+  }
+
   ngOnInit(): void {
     this.action = this.actionLabels.CREATE;
-    this.directoryStore.loadDirectories(this.id, '/', 3);
+    if (this.id) {
+      this.directoryStore.loadDirectories(this.id, '/', 3);
+    }
     this.createForm();
+    if (this.hideDirectory && this.path) {
+      this.applyDirectoryPath(this.path);
+    }
     this.isEdit ? this.populateForm() : this.loadingReady();
 
     this.snapScheduleForm
       .get('directory')
       .valueChanges.pipe(
-        filter(() => !this.isEdit),
+        filter(() => !this.isEdit && !this.hideDirectory),
         debounceTime(DEBOUNCE_TIMER),
         tap(() => {
           this.isSubvolume = false;
@@ -273,6 +328,51 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni
       .join('|');
   }
 
+  getScheduleSummary(): string {
+    if (!this.snapScheduleForm) {
+      return '';
+    }
+
+    const values = this.snapScheduleForm.getRawValue() as SnapshotScheduleFormValue;
+    const schedule = this.parseSchedule(values?.repeatInterval, values?.repeatFrequency);
+    const retention = this.parseRetentionPolicies(values?.retentionPolicies);
+    const parts = [$localize`Every ${schedule}`];
+
+    if (retention) {
+      parts.push($localize`Retention: ${retention}`);
+    }
+
+    return parts.join(' · ');
+  }
+
+  buildCreatePayload(targetPath?: string): Record<string, unknown> {
+    const values = this.snapScheduleForm.getRawValue() as SnapshotScheduleFormValue;
+    const path = targetPath ?? values.directory;
+    const snapScheduleObj: Record<string, unknown> = {
+      fs: this.fsName,
+      path,
+      snap_schedule: this.parseSchedule(values?.repeatInterval, values?.repeatFrequency),
+      start: new Date(values?.startDate.replace(/\//g, '-').replace(' ', 'T'))
+        .toISOString()
+        .slice(0, 19)
+    };
+
+    const retentionPoliciesValues = this.parseRetentionPolicies(values?.retentionPolicies);
+    if (retentionPoliciesValues) {
+      snapScheduleObj['retention_policy'] = retentionPoliciesValues;
+    }
+
+    if (this.isSubvolume) {
+      snapScheduleObj['subvol'] = this.subvolume;
+    }
+
+    if (this.isSubvolume && !this.isDefaultSubvolumeGroup) {
+      snapScheduleObj['group'] = this.subvolumeGroup;
+    }
+
+    return snapScheduleObj;
+  }
+
   submit() {
     this.validateSchedule()(this.snapScheduleForm).subscribe({
       next: () => {
@@ -281,8 +381,6 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni
           return;
         }
 
-        const values = this.snapScheduleForm.value as SnapshotScheduleFormValue;
-
         if (this.isEdit) {
           const retentionPoliciesToAdd = (this.snapScheduleForm.get(
             'retentionPolicies'
@@ -321,28 +419,7 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni
               }
             });
         } else {
-          const snapScheduleObj = {
-            fs: this.fsName,
-            path: values.directory,
-            snap_schedule: this.parseSchedule(values?.repeatInterval, values?.repeatFrequency),
-            start: new Date(values?.startDate.replace(/\//g, '-').replace(' ', 'T'))
-              .toISOString()
-              .slice(0, 19)
-          };
-
-          const retentionPoliciesValues = this.parseRetentionPolicies(values?.retentionPolicies);
-
-          if (retentionPoliciesValues) {
-            snapScheduleObj['retention_policy'] = retentionPoliciesValues;
-          }
-
-          if (this.isSubvolume) {
-            snapScheduleObj['subvol'] = this.subvolume;
-          }
-
-          if (this.isSubvolume && !this.isDefaultSubvolumeGroup) {
-            snapScheduleObj['group'] = this.subvolumeGroup;
-          }
+          const snapScheduleObj = this.buildCreatePayload();
           this.taskWrapper
             .wrapTaskAroundCall({
               task: new FinishedTask('cephfs/snapshot/schedule/' + URLVerbs.CREATE, {
@@ -368,6 +445,9 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni
       const directory = frm.get('directory');
       const repeatFrequency = frm.get('repeatFrequency');
       const repeatInterval = frm.get('repeatInterval');
+      const directoryPath = this.hideDirectory
+        ? directory?.getRawValue?.() ?? directory?.value
+        : directory?.value;
 
       if (this.isEdit) {
         return of(null);
@@ -377,7 +457,7 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni
         switchMap(() =>
           this.snapScheduleService
             .checkScheduleExists(
-              directory?.value,
+              directoryPath,
               this.fsName,
               repeatInterval?.value,
               repeatFrequency?.value,
@@ -403,6 +483,29 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni
     return (frm.get(frmArrayName) as FormArray)?.controls?.[idx]?.get?.(ctrl);
   }
 
+  private applyDirectoryPath(path: string): void {
+    const directoryControl = this.snapScheduleForm.get('directory');
+    directoryControl.setValue(path);
+    directoryControl.disable();
+    this.subvolumeGroup = path?.split?.('/')?.[2];
+    this.subvolume = path?.split?.('/')?.[3];
+
+    if (!this.subvolume || !this.subvolumeGroup || !this.fsName) {
+      return;
+    }
+
+    this.subvolumeService
+      .exists(
+        this.subvolume,
+        this.fsName,
+        this.subvolumeGroup === DEFAULT_SUBVOLUME_GROUP ? '' : this.subvolumeGroup
+      )
+      .subscribe((exists: boolean) => {
+        this.isSubvolume = exists;
+        this.isDefaultSubvolumeGroup = exists && this.subvolumeGroup === DEFAULT_SUBVOLUME_GROUP;
+      });
+  }
+
   validateRetention() {
     return (frm: FormGroup) => {
       return timer(VALIDATON_TIMER).pipe(
@@ -425,7 +528,7 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni
           }
           return this.snapScheduleService
             .checkRetentionPolicyExists(
-              frm.get('directory').value,
+              frm.get('directory').getRawValue?.() ?? frm.get('directory').value,
               this.fsName,
               retentionList,
               this.retentionPoliciesToRemove?.map?.((rp) => rp.retentionFrequency) || [],
index 155669bc73ec278af28d305cb3565c585d7af217..9d15119bf2845d8f2ee242b80d1f1bddb86db45e 100644 (file)
@@ -35,6 +35,7 @@ import { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mir
 import { CephfsMirroringErrorComponent } from './cephfs-mirroring-error/cephfs-mirroring-error.component';
 import { CephfsAddMirroringPathComponent } from './cephfs-add-mirroring-path/cephfs-add-mirroring-path.component';
 import { MirroringPathsStepComponent } from './cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component';
+import { MirroringReviewStepComponent } from './cephfs-add-mirroring-path/mirroring-review-step/mirroring-review-step.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';
@@ -155,7 +156,8 @@ import FolderIcon16 from '@carbon/icons/es/folder/16';
     CephfsDownloadTokenComponent,
     CephfsSetupMirroringComponent,
     CephfsAddMirroringPathComponent,
-    MirroringPathsStepComponent
+    MirroringPathsStepComponent,
+    MirroringReviewStepComponent
   ],
   providers: [provideCharts(withDefaultRegisterables())],
   schemas: [CUSTOM_ELEMENTS_SCHEMA]