]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: added snap schedule form 54900/head
authorIvo Almeida <ialmeida@redhat.com>
Wed, 13 Dec 2023 01:08:52 +0000 (01:08 +0000)
committerIvo Almeida <ialmeida@redhat.com>
Wed, 31 Jan 2024 09:39:31 +0000 (09:39 +0000)
Fixes: https://tracker.ceph.com/issues/63827
Signed-off-by: Ivo Almeida <ialmeida@redhat.com>
16 files changed:
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-list/cephfs-snapshotschedule-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-snapshot-schedule.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/repeat-frequency.enum.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/retention-frequency.enum.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/snapshot-schedule.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/openapi.yaml

index 01827e3ef0ec3c7eb23037c31f53df321469bbb6..86f112522c8fc7821c594aa465e425e5282aff20 100644 (file)
@@ -941,25 +941,24 @@ class CephFsSnapshotClone(RESTController):
         return f'Clone {clone_name} created successfully'
 
 
-@APIRouter('/cephfs/snaphost/schedule', Scope.CEPHFS)
+@APIRouter('/cephfs/snapshot/schedule', Scope.CEPHFS)
 @APIDoc("Cephfs Snapshot Scheduling API", "CephFSSnapshotSchedule")
 class CephFSSnapshotSchedule(RESTController):
 
     def list(self, fs: str, path: str = '/', recursive: bool = True):
         error_code, out, err = mgr.remote('snap_schedule', 'snap_schedule_list',
-                                          path, recursive, fs, 'plain')
-
+                                          path, recursive, fs, None, None, 'plain')
         if len(out) == 0:
             return []
 
         snapshot_schedule_list = out.split('\n')
-        output = []
+        output: list[Any] = []
 
         for snap in snapshot_schedule_list:
             current_path = snap.strip().split(' ')[0]
             error_code, status_out, err = mgr.remote('snap_schedule', 'snap_schedule_get',
-                                                     current_path, fs, 'plain')
-            output.append(json.loads(status_out))
+                                                     current_path, fs, None, None, 'json')
+            output = output + json.loads(status_out)
 
         output_json = json.dumps(output)
 
@@ -967,5 +966,34 @@ class CephFSSnapshotSchedule(RESTController):
             raise DashboardException(
                 f'Failed to get list of snapshot schedules for path {path}: {err}'
             )
-
         return json.loads(output_json)
+
+    def create(self, fs: str, path: str, snap_schedule: str, start: str, retention_policy=None):
+        error_code, _, err = mgr.remote('snap_schedule',
+                                        'snap_schedule_add',
+                                        path,
+                                        snap_schedule,
+                                        start,
+                                        fs)
+
+        if retention_policy:
+            retention_policies = retention_policy.split('|')
+            for retention in retention_policies:
+                retention_count = retention.split('-')[0]
+                retention_spec_or_period = retention.split('-')[1]
+                error_code_retention, _, err_retention = mgr.remote('snap_schedule',
+                                                                    'snap_schedule_retention_add',
+                                                                    path,
+                                                                    retention_spec_or_period,
+                                                                    retention_count,
+                                                                    fs)
+                if error_code_retention != 0:
+                    raise DashboardException(
+                        f'Failed to add retention policy for path {path}: {err_retention}'
+                    )
+        if error_code != 0:
+            raise DashboardException(
+                f'Failed to create snapshot schedule for path {path}: {err}'
+            )
+
+        return f'Snapshot schedule for path {path} created successfully'
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.html
new file mode 100644 (file)
index 0000000..9e9cde8
--- /dev/null
@@ -0,0 +1,179 @@
+<cd-modal [modalRef]="activeModal">
+  <ng-container i18n="form title"
+                class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
+    <ng-container class="modal-content"
+                  *cdFormLoading="loading">
+    <form name="snapScheduleForm"
+          #formDir="ngForm"
+          [formGroup]="snapScheduleForm"
+          novalidate>
+      <div class="modal-body">
+        <!-- Directory -->
+        <div class="form-group row">
+          <label class="cd-col-form-label required"
+                 for="directory"
+                 i18n>Directory
+          </label>
+          <div class="cd-col-form-input">
+            <ng-template #loading>
+              <i [ngClass]="[icons.spinner, icons.spin, 'mt-2', 'me-2']"></i>
+              <span i18n>Loading directories</span>
+            </ng-template>
+            <select class="form-select"
+                    id="directory"
+                    name="directory"
+                    formControlName="directory"
+                    *ngIf="directories$ | async as directories; else loading">
+              <option [ngValue]="null"
+                      i18n>--Select a directory--</option>
+              <option *ngFor="let dir of directories"
+                      [value]="dir.path">{{ dir.path }}</option>
+            </select>
+            <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>
+          </div>
+        </div>
+        <!--Start date -->
+        <div class="form-group row">
+          <label class="cd-col-form-label required"
+                 for="startDate"
+                 i18n>Start date
+          </label>
+          <div class="cd-col-form-input">
+            <div class="input-group">
+              <input class="form-control"
+                     placeholder="yyyy-mm-dd"
+                     name="startDate"
+                     id="startDate"
+                     formControlName="startDate"
+                     [minDate]="minDate"
+                     ngbDatepicker
+                     #d="ngbDatepicker"
+                     (click)="d.open()">
+              <button type="button"
+                      class="btn btn-light"
+                      (click)="d.toggle()"
+                      title="Open">
+                <i [ngClass]="icons.calendar"></i>
+              </button>
+            </div>
+            <span class="invalid-feedback"
+                  *ngIf="snapScheduleForm.showError('startDate', formDir, 'required')"
+                  i18n>This field is required.</span>
+          </div>
+        </div>
+        <!-- Start time -->
+        <div class="form-group row">
+          <label class="cd-col-form-label required"
+                 for="startTime"
+                 i18n>Start time
+            <cd-helper>The time zone is assumed to be UTC.</cd-helper>
+          </label>
+          <div class="cd-col-form-input">
+            <ngb-timepicker [spinners]="false"
+                            [seconds]="false"
+                            [meridian]="true"
+                            formControlName="startTime"
+                            id="startTime"
+                            name="startTime"></ngb-timepicker>
+            <span class="invalid-feedback"
+                  *ngIf="snapScheduleForm.showError('startTime', formDir, 'required')"
+                  i18n>This field is required.</span>
+          </div>
+        </div>
+        <!-- Repeat interval -->
+        <div class="form-group row">
+          <label class="cd-col-form-label required"
+                 for="repeatInterval"
+                 i18n>Schedule
+          </label>
+          <div class="cd-col-form-input">
+            <div class="input-group">
+              <input class="form-control"
+                     type="number"
+                     min="1"
+                     id="repeatInterval"
+                     name="repeatInterval"
+                     formControlName="repeatInterval">
+              <select [ngClass]="['form-select', 'me-5']"
+                      id="repeatFrequency"
+                      name="repeatFrequency"
+                      formControlName="repeatFrequency"
+                      *ngIf="repeatFrequencies">
+                <option *ngFor="let freq of repeatFrequencies"
+                        [value]="freq[1]"
+                        i18n>{{ freq[0] }}</option>
+              </select>
+            </div>
+            <span class="invalid-feedback"
+                  *ngIf="snapScheduleForm.showError('repeatFrequency', formDir, 'notUnique')"
+                  i18n>This schedule already exists for the selected directory.</span>
+            <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>
+          </div>
+        </div>
+        <!-- Retention policies -->
+        <ng-container formArrayName="retentionPolicies"
+                      *ngFor="let retentionPolicy of retentionPolicies.controls; index as i">
+          <ng-container [formGroupName]="i">
+            <div class="form-group row">
+              <label [ngClass]="{'cd-col-form-label': true, 'visible': i == 0, 'invisible': i > 0}"
+                    for="retentionInterval"
+                    i18n>Retention policy
+              </label>
+              <div class="cd-col-form-input">
+                <div class="input-group">
+                  <input class="form-control"
+                         type="number"
+                         min="1"
+                         id="retentionInterval"
+                         name="retentionInterval"
+                         formControlName="retentionInterval">
+                  <select class="form-select"
+                          id="retentionFrequency"
+                          name="retentionFrequency"
+                          formControlName="retentionFrequency"
+                          *ngIf="retentionFrequencies">
+                    <option *ngFor="let freq of retentionFrequencies"
+                            [value]="freq[1]"
+                            i18n>{{ freq[0] }}</option>
+                  </select>
+                  <button class="btn btn-light"
+                          type="button"
+                          (click)="removeRetentionPolicy(i)">
+                    <i [ngClass]="[icons.trash]"></i>
+                  </button>
+                </div>
+                <span class="invalid-feedback"
+                      *ngIf="snapScheduleForm.controls['retentionPolicies'].controls[i].invalid"
+                      i18n>This retention policy already exists for the selected directory.</span>
+              </div>
+            </div>
+          </ng-container>
+        </ng-container>
+        <div class="d-flex flex-row align-content-center justify-content-end">
+          <button class="btn btn-light"
+                  type="button"
+                  (click)="addRetentionPolicy()">
+            <i [ngClass]="[icons.add, 'me-2']"></i>
+            <span i18n>Add retention policy</span>
+          </button>
+        </div>
+      </div>
+
+      <div class="modal-footer">
+        <cd-form-button-panel (submitActionEvent)="submit()"
+                              [form]="snapScheduleForm"
+                              [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.spec.ts
new file mode 100644 (file)
index 0000000..6a9fbcb
--- /dev/null
@@ -0,0 +1,79 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CephfsSnapshotscheduleFormComponent } from './cephfs-snapshotschedule-form.component';
+import {
+  NgbActiveModal,
+  NgbDatepickerModule,
+  NgbTimepickerModule
+} from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { SharedModule } from '~/app/shared/shared.module';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { FormHelper, configureTestBed } from '~/testing/unit-test-helper';
+import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service';
+
+describe('CephfsSnapshotscheduleFormComponent', () => {
+  let component: CephfsSnapshotscheduleFormComponent;
+  let fixture: ComponentFixture<CephfsSnapshotscheduleFormComponent>;
+  let formHelper: FormHelper;
+  let createSpy: jasmine.Spy;
+
+  configureTestBed({
+    declarations: [CephfsSnapshotscheduleFormComponent],
+    providers: [NgbActiveModal],
+    imports: [
+      SharedModule,
+      ToastrModule.forRoot(),
+      ReactiveFormsModule,
+      HttpClientTestingModule,
+      RouterTestingModule,
+      NgbDatepickerModule,
+      NgbTimepickerModule
+    ]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(CephfsSnapshotscheduleFormComponent);
+    component = fixture.componentInstance;
+    component.fsName = 'test_fs';
+    component.ngOnInit();
+    formHelper = new FormHelper(component.snapScheduleForm);
+    createSpy = spyOn(TestBed.inject(CephfsSnapshotScheduleService), 'create').and.stub();
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should have a form open in modal', () => {
+    const nativeEl = fixture.debugElement.nativeElement;
+    expect(nativeEl.querySelector('cd-modal')).not.toBe(null);
+  });
+
+  it('should submit the form', () => {
+    const input = {
+      directory: '/test',
+      startDate: {
+        year: 2023,
+        month: 11,
+        day: 14
+      },
+      startTime: {
+        hour: 0,
+        minute: 6,
+        second: 22
+      },
+      repeatInterval: 4,
+      repeatFrequency: 'h'
+    };
+
+    formHelper.setMultipleValues(input);
+    component.snapScheduleForm.get('directory').setValue('/test');
+    component.submit();
+
+    expect(createSpy).toHaveBeenCalled();
+  });
+});
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
new file mode 100644 (file)
index 0000000..5b6d900
--- /dev/null
@@ -0,0 +1,257 @@
+import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
+import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
+import { NgbActiveModal, NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap';
+import { uniq } from 'lodash';
+import { Observable, timer } from 'rxjs';
+import { map, switchMap } from 'rxjs/operators';
+import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service';
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { RepeatFrequency } from '~/app/shared/enum/repeat-frequency.enum';
+import { RetentionFrequency } from '~/app/shared/enum/retention-frequency.enum';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CephfsDir } from '~/app/shared/models/cephfs-directory-models';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { RetentionPolicy, SnapshotScheduleFormValue } from '~/app/shared/models/snapshot-schedule';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+const VALIDATON_TIMER = 300;
+
+@Component({
+  selector: 'cd-cephfs-snapshotschedule-form',
+  templateUrl: './cephfs-snapshotschedule-form.component.html',
+  styleUrls: ['./cephfs-snapshotschedule-form.component.scss']
+})
+export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnInit {
+  fsName!: string;
+  id!: number;
+  isEdit = false;
+  icons = Icons;
+  repeatFrequencies = Object.entries(RepeatFrequency);
+  retentionFrequencies = Object.entries(RetentionFrequency);
+
+  currentTime!: NgbTimeStruct;
+  minDate!: NgbDateStruct;
+
+  snapScheduleForm!: CdFormGroup;
+
+  action!: string;
+  resource!: string;
+
+  columns!: CdTableColumn[];
+  directories$!: Observable<CephfsDir[]>;
+
+  constructor(
+    public activeModal: NgbActiveModal,
+    private actionLabels: ActionLabelsI18n,
+    private cephfsService: CephfsService,
+    private snapScheduleService: CephfsSnapshotScheduleService,
+    private taskWrapper: TaskWrapperService,
+    private cd: ChangeDetectorRef
+  ) {
+    super();
+    this.resource = $localize`Snapshot schedule`;
+
+    const currentDatetime = new Date();
+    this.minDate = {
+      year: currentDatetime.getUTCFullYear(),
+      month: currentDatetime.getUTCMonth() + 1,
+      day: currentDatetime.getUTCDate()
+    };
+    this.currentTime = {
+      hour: currentDatetime.getUTCHours(),
+      minute: currentDatetime.getUTCMinutes(),
+      second: currentDatetime.getUTCSeconds()
+    };
+  }
+
+  ngOnInit(): void {
+    this.action = this.actionLabels.CREATE;
+    this.directories$ = this.cephfsService.lsDir(this.id, '/', 3);
+    this.createForm();
+    this.loadingReady();
+  }
+
+  get retentionPolicies() {
+    return this.snapScheduleForm.get('retentionPolicies') as FormArray;
+  }
+
+  createForm() {
+    this.snapScheduleForm = new CdFormGroup(
+      {
+        directory: new FormControl(undefined, {
+          validators: [Validators.required]
+        }),
+        startDate: new FormControl(this.minDate, {
+          validators: [Validators.required]
+        }),
+        startTime: new FormControl(this.currentTime, {
+          validators: [Validators.required]
+        }),
+        repeatInterval: new FormControl(1, {
+          validators: [Validators.required, Validators.min(1)]
+        }),
+        repeatFrequency: new FormControl(RepeatFrequency.Daily, {
+          validators: [Validators.required]
+        }),
+        retentionPolicies: new FormArray([])
+      },
+      {
+        asyncValidators: [this.validateSchedule(), this.validateRetention()]
+      }
+    );
+  }
+
+  addRetentionPolicy() {
+    this.retentionPolicies.push(
+      new FormGroup({
+        retentionInterval: new FormControl(1),
+        retentionFrequency: new FormControl(RetentionFrequency.Daily)
+      })
+    );
+    this.cd.detectChanges();
+  }
+
+  removeRetentionPolicy(idx: number) {
+    this.retentionPolicies.removeAt(idx);
+    this.cd.detectChanges();
+  }
+
+  parseDatetime(date: NgbDateStruct, time?: NgbTimeStruct): string {
+    return `${date.year}-${date.month}-${date.day}T${time.hour || '00'}:${time.minute || '00'}:${
+      time.second || '00'
+    }`;
+  }
+  parseSchedule(interval: number, frequency: string): string {
+    return `${interval}${frequency}`;
+  }
+
+  parseRetentionPolicies(retentionPolicies: RetentionPolicy[]) {
+    return retentionPolicies
+      ?.filter((r) => r?.retentionInterval !== null && r?.retentionFrequency !== null)
+      ?.map?.((r) => `${r.retentionInterval}-${r.retentionFrequency}`)
+      .join('|');
+  }
+
+  submit() {
+    if (this.snapScheduleForm.invalid) {
+      this.snapScheduleForm.setErrors({ cdSubmitButton: true });
+      return;
+    }
+
+    const values = this.snapScheduleForm.value as SnapshotScheduleFormValue;
+
+    const snapScheduleObj = {
+      fs: this.fsName,
+      path: values.directory,
+      snap_schedule: this.parseSchedule(values.repeatInterval, values.repeatFrequency),
+      start: this.parseDatetime(values.startDate, values.startTime)
+    };
+
+    const retentionPoliciesValues = this.parseRetentionPolicies(values?.retentionPolicies);
+    if (retentionPoliciesValues) {
+      snapScheduleObj['retention_policy'] = retentionPoliciesValues;
+    }
+
+    this.taskWrapper
+      .wrapTaskAroundCall({
+        task: new FinishedTask('cephfs/snapshot/schedule/' + URLVerbs.CREATE, {
+          path: snapScheduleObj.path
+        }),
+        call: this.snapScheduleService.create(snapScheduleObj)
+      })
+      .subscribe({
+        error: () => {
+          this.snapScheduleForm.setErrors({ cdSubmitButton: true });
+        },
+        complete: () => {
+          this.activeModal.close();
+        }
+      });
+  }
+
+  validateSchedule() {
+    return (frm: AbstractControl) => {
+      const directory = frm.get('directory');
+      const repeatFrequency = frm.get('repeatFrequency');
+      const repeatInterval = frm.get('repeatInterval');
+      return timer(VALIDATON_TIMER).pipe(
+        switchMap(() =>
+          this.snapScheduleService
+            .checkScheduleExists(
+              directory?.value,
+              this.fsName,
+              repeatInterval?.value,
+              repeatFrequency?.value
+            )
+            .pipe(
+              map((exists: boolean) => {
+                if (exists) {
+                  repeatFrequency?.setErrors({ notUnique: true }, { emitEvent: true });
+                } else {
+                  repeatFrequency?.setErrors(null);
+                }
+                return null;
+              })
+            )
+        )
+      );
+    };
+  }
+
+  getFormArrayItem(frm: FormGroup, frmArrayName: string, ctrl: string, idx: number) {
+    return (frm.get(frmArrayName) as FormArray)?.controls?.[idx]?.get?.(ctrl);
+  }
+
+  validateRetention() {
+    return (frm: FormGroup) => {
+      return timer(VALIDATON_TIMER).pipe(
+        switchMap(() => {
+          const retentionList = (frm.get('retentionPolicies') as FormArray).controls?.map(
+            (ctrl) => {
+              return ctrl.get('retentionFrequency').value;
+            }
+          );
+          if (uniq(retentionList)?.length !== retentionList?.length) {
+            this.getFormArrayItem(
+              frm,
+              'retentionPolicies',
+              'retentionFrequency',
+              retentionList.length - 1
+            )?.setErrors?.({
+              notUnique: true
+            });
+            return null;
+          }
+          return this.snapScheduleService
+            .checkRetentionPolicyExists(frm.get('directory').value, this.fsName, retentionList)
+            .pipe(
+              map(({ exists, errorIndex }) => {
+                if (exists) {
+                  this.getFormArrayItem(
+                    frm,
+                    'retentionPolicies',
+                    'retentionFrequency',
+                    errorIndex
+                  )?.setErrors?.({ notUnique: true });
+                } else {
+                  (frm.get('retentionPolicies') as FormArray).controls?.forEach?.((_, i) => {
+                    this.getFormArrayItem(
+                      frm,
+                      'retentionPolicies',
+                      'retentionFrequency',
+                      i
+                    )?.setErrors?.(null);
+                  });
+                }
+                return null;
+              })
+            );
+        })
+      );
+    };
+  }
+}
index 14752b7e2a8f39eb094a822184393b5f943293c3..6b406cfc1712f7bfff02a0d3d790feea10cf8902 100644 (file)
@@ -26,6 +26,8 @@ import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { BlockUI, NgBlockUI } from 'ng-block-ui';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CephfsSnapshotscheduleFormComponent } from '../cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component';
 
 @Component({
   selector: 'cd-cephfs-snapshotschedule-list',
@@ -36,6 +38,7 @@ export class CephfsSnapshotscheduleListComponent
   extends CdForm
   implements OnInit, OnChanges, OnDestroy {
   @Input() fsName!: string;
+  @Input() id!: number;
 
   @ViewChild('pathTpl', { static: true })
   pathTpl: any;
@@ -65,7 +68,8 @@ export class CephfsSnapshotscheduleListComponent
     private authStorageService: AuthStorageService,
     private modalService: ModalService,
     private mgrModuleService: MgrModuleService,
-    private notificationService: NotificationService
+    private notificationService: NotificationService,
+    private actionLables: ActionLabelsI18n
   ) {
     super();
     this.permissions = this.authStorageService.getPermissions();
@@ -112,7 +116,14 @@ export class CephfsSnapshotscheduleListComponent
       { prop: 'created', name: $localize`Created`, cellTransformation: CellTemplate.timeAgo }
     ];
 
-    this.tableActions = [];
+    this.tableActions = [
+      {
+        name: this.actionLables.CREATE,
+        permission: 'create',
+        icon: Icons.add,
+        click: () => this.openModal(true)
+      }
+    ];
   }
 
   ngOnDestroy(): void {
@@ -129,9 +140,11 @@ export class CephfsSnapshotscheduleListComponent
 
   openModal(edit = false) {
     this.modalService.show(
-      {},
+      CephfsSnapshotscheduleFormComponent,
       {
-        fsName: 'fs1',
+        fsName: this.fsName,
+        id: this.id,
+        path: this.selection?.first()?.path,
         isEdit: edit
       },
       { size: 'lg' }
index d21d47034f802e6433d77bcb1776e560fff59001..a840692ed7673dc29ab216fee9984711061a2198 100644 (file)
@@ -59,6 +59,7 @@
       <ng-template ngbNavContent>
         <cd-cephfs-snapshotschedule-list
           [fsName]="selection.mdsmap.fs_name"
+          [id]="id"
         ></cd-cephfs-snapshotschedule-list>
       </ng-template>
     </ng-container>
index 36ad6ddcb87455bcaf1688169ce0a36164aa10e2..b13273dc4a21e3fcfc0a85795d99b7370864359b 100644 (file)
@@ -3,7 +3,13 @@ import { NgModule } from '@angular/core';
 import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 
 import { TreeModule } from '@circlon/angular-tree-component';
-import { NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+import {
+  NgbDatepickerModule,
+  NgbNavModule,
+  NgbTimepickerModule,
+  NgbTooltipModule,
+  NgbTypeaheadModule
+} from '@ng-bootstrap/ng-bootstrap';
 import { NgChartsModule } from 'ng2-charts';
 
 import { AppRoutingModule } from '~/app/app-routing.module';
@@ -23,6 +29,7 @@ import { CephfsSubvolumeSnapshotsListComponent } from './cephfs-subvolume-snapsh
 import { CephfsSnapshotscheduleListComponent } from './cephfs-snapshotschedule-list/cephfs-snapshotschedule-list.component';
 import { DataTableModule } from '../../shared/datatable/datatable.module';
 import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-form/cephfs-subvolume-snapshots-form.component';
+import { CephfsSnapshotscheduleFormComponent } from './cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component';
 
 @NgModule({
   imports: [
@@ -36,7 +43,9 @@ import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapsh
     ReactiveFormsModule,
     NgbTypeaheadModule,
     NgbTooltipModule,
-    DataTableModule
+    DataTableModule,
+    NgbDatepickerModule,
+    NgbTimepickerModule
   ],
   declarations: [
     CephfsDetailComponent,
@@ -53,6 +62,7 @@ import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapsh
     CephfsSubvolumegroupFormComponent,
     CephfsSubvolumeSnapshotsListComponent,
     CephfsSnapshotscheduleListComponent,
+    CephfsSnapshotscheduleFormComponent,
     CephfsSubvolumeSnapshotsFormComponent
   ]
 })
index ec9f58c0feec6713aa0abb3e2293aa6fdb84dd0e..0666bb179e834dacdfc7c05b3d5250206b0cf53b 100644 (file)
@@ -1,8 +1,11 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 import { Observable } from 'rxjs/internal/Observable';
+import { catchError, map } from 'rxjs/operators';
+import { intersection, isEqual, uniqWith } from 'lodash';
 import { SnapshotSchedule } from '../models/snapshot-schedule';
-import { map } from 'rxjs/operators';
+import { of } from 'rxjs';
+import { RepeatFrequency } from '../enum/repeat-frequency.enum';
 
 @Injectable({
   providedIn: 'root'
@@ -12,28 +15,86 @@ export class CephfsSnapshotScheduleService {
 
   constructor(private http: HttpClient) {}
 
-  getSnapshotScheduleList(
+  create(data: Record<string, any>): Observable<any> {
+    return this.http.post(`${this.baseURL}/snapshot/schedule`, data, { observe: 'response' });
+  }
+
+  checkScheduleExists(
     path: string,
     fs: string,
-    recursive = true
-  ): Observable<SnapshotSchedule[]> {
+    interval: number,
+    frequency: RepeatFrequency
+  ): Observable<boolean> {
+    return this.getSnapshotScheduleList(path, fs, false).pipe(
+      map((response) => {
+        const index = response.findIndex(
+          (x) => x.path === path && x.schedule === `${interval}${frequency}`
+        );
+        return index > -1;
+      }),
+      catchError(() => {
+        return of(false);
+      })
+    );
+  }
+
+  checkRetentionPolicyExists(
+    path: string,
+    fs: string,
+    retentionFrequencies: string[]
+  ): Observable<{ exists: boolean; errorIndex: number }> {
+    return this.getList(path, fs, false).pipe(
+      map((response) => {
+        let errorIndex = -1;
+        let exists = false;
+        const index = response.findIndex((x) => x.path === path);
+        const result = retentionFrequencies?.length
+          ? intersection(Object.keys(response?.[index]?.retention), retentionFrequencies)
+          : [];
+        exists = !!result?.length;
+        result?.forEach((r) => (errorIndex = retentionFrequencies.indexOf(r)));
+
+        return { exists, errorIndex };
+      }),
+      catchError(() => {
+        return of({ exists: false, errorIndex: -1 });
+      })
+    );
+  }
+
+  private getList(path: string, fs: string, recursive = true): Observable<SnapshotSchedule[]> {
     return this.http
       .get<SnapshotSchedule[]>(
-        `${this.baseURL}/snaphost/schedule?path=${path}&fs=${fs}&recursive=${recursive}`
+        `${this.baseURL}/snapshot/schedule?path=${path}&fs=${fs}&recursive=${recursive}`
       )
       .pipe(
-        map((snapList: SnapshotSchedule[]) =>
+        catchError(() => {
+          return of([]);
+        })
+      );
+  }
+
+  getSnapshotScheduleList(
+    path: string,
+    fs: string,
+    recursive = true
+  ): Observable<SnapshotSchedule[]> {
+    return this.getList(path, fs, recursive).pipe(
+      map((snapList: SnapshotSchedule[]) =>
+        uniqWith(
           snapList.map((snapItem: SnapshotSchedule) => ({
             ...snapItem,
             status: snapItem.active ? 'Active' : 'Inactive',
             subvol: snapItem?.subvol || ' - ',
-            retention: Object.values(snapItem.retention)?.length
+            retention: Object.values(snapItem?.retention || [])?.length
               ? Object.entries(snapItem.retention)
                   ?.map?.(([frequency, interval]) => `${interval}${frequency.toLocaleUpperCase()}`)
                   .join(' ')
               : '-'
-          }))
+          })),
+          isEqual
         )
-      );
+      )
+    );
   }
 }
index 6142d7359de26614225c8d2476dd1772f41d3e73..a265ae7a265864816bdb8dedea3bdfffc1a34f69 100644 (file)
@@ -21,8 +21,8 @@ export class CephfsService {
     return this.http.get(`${this.baseURL}`);
   }
 
-  lsDir(id: number, path?: string): Observable<CephfsDir[]> {
-    let apiPath = `${this.baseUiURL}/${id}/ls_dir?depth=2`;
+  lsDir(id: number, path?: string, depth: number = 2): Observable<CephfsDir[]> {
+    let apiPath = `${this.baseUiURL}/${id}/ls_dir?depth=${depth}`;
     if (path) {
       apiPath += `&path=${encodeURIComponent(path)}`;
     }
index d3515fb87e4b5a58331930e072e9e72b3c166798..be454076b862142df628a4d189fe03b3380ff712 100644 (file)
@@ -82,6 +82,7 @@ export enum Icons {
   navicon = 'fa fa-navicon', // Navigation
   areaChart = 'fa fa-area-chart', // Area Chart, dashboard
   eye = 'fa fa-eye', // Observability
+  calendar = 'fa fa-calendar',
   externalUrl = 'fa fa-external-link', // links to external page
 
   /* Icons for special effect */
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/repeat-frequency.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/repeat-frequency.enum.ts
new file mode 100644 (file)
index 0000000..db3563e
--- /dev/null
@@ -0,0 +1,5 @@
+export enum RepeatFrequency {
+  Hourly = 'h',
+  Daily = 'd',
+  Weekly = 'w'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/retention-frequency.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/retention-frequency.enum.ts
new file mode 100644 (file)
index 0000000..44714da
--- /dev/null
@@ -0,0 +1,8 @@
+export enum RetentionFrequency {
+  Hourly = 'h',
+  Daily = 'd',
+  Weekly = 'w',
+  Monthly = 'm',
+  Yearly = 'y',
+  'lastest snapshots' = 'n'
+}
index b1cea7466f672efc2256889ef1df5a0d1960cb0b..af3b0f7c5e6b95f16bea2a559225b53dbe2cef12 100644 (file)
@@ -1,3 +1,5 @@
+import { NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap';
+
 export interface SnapshotSchedule {
   fs?: string;
   subvol?: string;
@@ -15,3 +17,17 @@ export interface SnapshotSchedule {
   active: boolean;
   status: 'Active' | 'Inactive';
 }
+
+export interface SnapshotScheduleFormValue {
+  directory: string;
+  startDate: NgbDateStruct;
+  startTime: NgbTimeStruct;
+  repeatInterval: number;
+  repeatFrequency: string;
+  retentionPolicies: RetentionPolicy[];
+}
+
+export interface RetentionPolicy {
+  retentionInterval: number;
+  retentionFrequency: string;
+}
index dba742fbf7831be105feeb8eb5ead7729fd18304..f631842919c0f8beda65d26d204d91ee038d587c 100644 (file)
@@ -387,6 +387,9 @@ export class TaskMessageService {
     'cephfs/subvolume/snapshot/delete': this.newTaskMessage(
       this.commonOperations.delete,
       (metadata) => this.snapshot(metadata)
+    ),
+    'cephfs/snapshot/schedule/create': this.newTaskMessage(this.commonOperations.add, (metadata) =>
+      this.snapshotSchedule(metadata)
     )
   };
 
@@ -459,6 +462,9 @@ export class TaskMessageService {
     return $localize`snapshot '${metadata.snapshotName}'`;
   }
 
+  snapshotSchedule(metadata: any) {
+    return $localize`snapshot schedule for path '${metadata?.path}'`;
+  }
   crudMessageId(id: string) {
     return $localize`${id}`;
   }
index 54f21e9484ae4fdbd9fc263708fd1e649636c8a0..ad681795dc07e98e286cc8ace311be303d22e29c 100644 (file)
@@ -1758,7 +1758,7 @@ paths:
       summary: Rename CephFS Volume
       tags:
       - Cephfs
-  /api/cephfs/snaphost/schedule:
+  /api/cephfs/snapshot/schedule:
     get:
       parameters:
       - in: query
@@ -1795,6 +1795,53 @@ paths:
       - jwt: []
       tags:
       - CephFSSnapshotSchedule
+    post:
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                fs:
+                  type: string
+                path:
+                  type: string
+                retention_policy:
+                  type: string
+                snap_schedule:
+                  type: string
+                start:
+                  type: string
+              required:
+              - fs
+              - path
+              - snap_schedule
+              - start
+              type: object
+      responses:
+        '201':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource created.
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - CephFSSnapshotSchedule
   /api/cephfs/subvolume:
     post:
       parameters: []