snapshot['info'] = json.loads(out)
return snapshots
+ @RESTController.Resource('GET')
+ def info(self, vol_name: str, subvol_name: str, snap_name: str, group_name: str = ''):
+ params = {'vol_name': vol_name, 'sub_name': subvol_name, 'snap_name': snap_name}
+ if group_name:
+ params['group_name'] = group_name
+ error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_snapshot_info', None,
+ params)
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to get info for subvolume snapshot {snap_name}: {err}'
+ )
+ return json.loads(out)
+
+ def create(self, vol_name: str, subvol_name: str, snap_name: str, group_name=''):
+ params = {'vol_name': vol_name, 'sub_name': subvol_name, 'snap_name': snap_name}
+ if group_name:
+ params['group_name'] = group_name
+
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_subvolume_snapshot_create', None,
+ params)
+
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to create subvolume snapshot {snap_name}: {err}'
+ )
+ return f'Subvolume snapshot {snap_name} created successfully'
+
@APIRouter('/cephfs/snaphost/schedule', Scope.CEPHFS)
@APIDoc("Cephfs Snapshot Scheduling API", "CephFSSnapshotSchedule")
--- /dev/null
+<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="snapshotForm"
+ #formDir="ngForm"
+ [formGroup]="snapshotForm"
+ novalidate>
+ <div class="modal-body">
+ <div class="form-group row">
+ <label class="cd-col-form-label required"
+ for="snapshotName"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ type="text"
+ placeholder="Snapshot name..."
+ id="snapshotName"
+ name="snapshotName"
+ formControlName="snapshotName"
+ autofocus>
+ <span class="invalid-feedback"
+ *ngIf="snapshotForm.showError('snapshotName', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="snapshotForm.showError('snapshotName', formDir, 'notUnique')"
+ i18n>The snapshot already exists.</span>
+ </div>
+ </div>
+
+ <!-- Volume name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="volumeName"
+ i18n>Volume name</label>
+ <div class="cd-col-form-input">
+ <input class="form-control"
+ id="volumeName"
+ name="volumeName"
+ formControlName="volumeName">
+ </div>
+ </div>
+
+ <!--Subvolume Group name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="subvolumeGroupName"
+ i18n>Subvolume group
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-select"
+ id="subvolumeGroupName"
+ name="subvolumeGroupName"
+ formControlName="subvolumeGroupName"
+ #selection
+ (change)="onSelectionChange(selection.value)"
+ *ngIf="subVolumeGroups">
+ <ng-container *ngFor="let subvolumegroup of subVolumeGroups">
+ <option *ngIf="subvolumegroup == ''"
+ value="">_nogroup</option>
+ <option [value]="subvolumegroup"
+ *ngIf="subvolumegroup !== ''">{{ subvolumegroup }}</option>
+ </ng-container>
+ </select>
+ </div>
+ </div>
+
+ <!--Subvolume name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="subVolumeName"
+ i18n>Subvolume
+ </label>
+ <div class="cd-col-form-input">
+ <select class="form-select"
+ id="subVolumeName"
+ name="subVolumeName"
+ formControlName="subVolumeName"
+ #selection
+ (change)="resetValidators(selection.value)"
+ *ngIf="subVolumes$ | async as subVolumes">
+ <option *ngFor="let subVolume of subVolumes"
+ [value]="subVolume.name">{{ subVolume.name }}</option>
+ </select>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer">
+ <cd-form-button-panel (submitActionEvent)="submit()"
+ [form]="snapshotForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapshots-form.component';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { SharedModule } from '~/app/shared/shared.module';
+import { ToastrModule } from 'ngx-toastr';
+import { ReactiveFormsModule } from '@angular/forms';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+describe('CephfsSubvolumeSnapshotsFormComponent', () => {
+ let component: CephfsSubvolumeSnapshotsFormComponent;
+ let fixture: ComponentFixture<CephfsSubvolumeSnapshotsFormComponent>;
+
+ configureTestBed({
+ declarations: [CephfsSubvolumeSnapshotsFormComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ SharedModule,
+ ToastrModule.forRoot(),
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ RouterTestingModule
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CephfsSubvolumeSnapshotsFormComponent);
+ component = fixture.componentInstance;
+ component.fsName = 'test_volume';
+ component.subVolumeName = 'test_subvolume';
+ component.subVolumeGroupName = 'test_subvolume_group';
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import moment from 'moment';
+import { Observable } from 'rxjs';
+import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CephfsSubvolume } from '~/app/shared/models/cephfs-subvolume.model';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+@Component({
+ selector: 'cd-cephfs-subvolume-snapshots-form',
+ templateUrl: './cephfs-subvolume-snapshots-form.component.html',
+ styleUrls: ['./cephfs-subvolume-snapshots-form.component.scss']
+})
+export class CephfsSubvolumeSnapshotsFormComponent extends CdForm implements OnInit {
+ fsName: string;
+ subVolumeName: string;
+ subVolumeGroupName: string;
+ subVolumeGroups: string[];
+
+ isEdit = false;
+
+ snapshotForm: CdFormGroup;
+
+ action: string;
+ resource: string;
+
+ subVolumes$: Observable<CephfsSubvolume[]>;
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ private actionLabels: ActionLabelsI18n,
+ private taskWrapper: TaskWrapperService,
+ private cephFsSubvolumeService: CephfsSubvolumeService
+ ) {
+ super();
+ this.resource = $localize`snapshot`;
+ this.action = this.actionLabels.CREATE;
+ }
+
+ ngOnInit(): void {
+ this.createForm();
+
+ this.subVolumes$ = this.cephFsSubvolumeService.get(this.fsName, this.subVolumeGroupName, false);
+ this.loadingReady();
+ }
+
+ createForm() {
+ this.snapshotForm = new CdFormGroup({
+ snapshotName: new FormControl(moment().toISOString(true), {
+ validators: [Validators.required],
+ asyncValidators: [
+ CdValidators.unique(
+ this.cephFsSubvolumeService.snapshotExists,
+ this.cephFsSubvolumeService,
+ null,
+ null,
+ this.fsName,
+ this.subVolumeName,
+ this.subVolumeGroupName
+ )
+ ]
+ }),
+ volumeName: new FormControl({ value: this.fsName, disabled: true }),
+ subVolumeName: new FormControl(this.subVolumeName),
+ subvolumeGroupName: new FormControl(this.subVolumeGroupName)
+ });
+ }
+
+ onSelectionChange(groupName: string) {
+ this.subVolumeGroupName = groupName;
+ this.subVolumes$ = this.cephFsSubvolumeService.get(this.fsName, this.subVolumeGroupName, false);
+ this.subVolumes$.subscribe((subVolumes) => {
+ this.subVolumeName = subVolumes[0].name;
+ this.snapshotForm.get('subVolumeName').setValue(this.subVolumeName);
+
+ this.resetValidators();
+ });
+ }
+
+ resetValidators(subVolumeName?: string) {
+ this.subVolumeName = subVolumeName;
+ this.snapshotForm
+ .get('snapshotName')
+ .setAsyncValidators(
+ CdValidators.unique(
+ this.cephFsSubvolumeService.snapshotExists,
+ this.cephFsSubvolumeService,
+ null,
+ null,
+ this.fsName,
+ this.subVolumeName,
+ this.subVolumeGroupName
+ )
+ );
+ this.snapshotForm.get('snapshotName').updateValueAndValidity();
+ }
+
+ submit() {
+ const snapshotName = this.snapshotForm.getValue('snapshotName');
+ const subVolumeName = this.snapshotForm.getValue('subVolumeName');
+ const subVolumeGroupName = this.snapshotForm.getValue('subvolumeGroupName');
+ const volumeName = this.snapshotForm.getValue('volumeName');
+
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('cephfs/subvolume/snapshot/' + URLVerbs.CREATE, {
+ snapshotName: snapshotName
+ }),
+ call: this.cephFsSubvolumeService.createSnapshot(
+ volumeName,
+ snapshotName,
+ subVolumeName,
+ subVolumeGroupName
+ )
+ })
+ .subscribe({
+ error: () => this.snapshotForm.setErrors({ cdSubmitButton: true }),
+ complete: () => this.activeModal.close()
+ });
+ }
+}
[columns]="columns"
selectionType="single"
[hasDetails]="false"
- (fetchData)="fetchData()"></cd-table>
+ (fetchData)="fetchData()"
+ (updateSelection)="updateSelection($event)">
+
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions [permission]="permissions.cephfs"
+ [selection]="selection"
+ class="btn-group"
+ id="cephfs-snapshot-actions"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
+ </cd-table>
</div>
</div>
<ng-template #noGroupsTpl>
import { catchError, shareReplay, switchMap, tap } from 'rxjs/operators';
import { CephfsSubvolumeGroupService } from '~/app/shared/api/cephfs-subvolume-group.service';
import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
import { CephfsSubvolume, SubvolumeSnapshot } from '~/app/shared/models/cephfs-subvolume.model';
+import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapshots-form/cephfs-subvolume-snapshots-form.component';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { Permissions } from '~/app/shared/models/permissions';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
@Component({
selector: 'cd-cephfs-subvolume-snapshots-list',
context: CdTableFetchDataContext;
columns: CdTableColumn[] = [];
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ permissions: Permissions;
subVolumes$: Observable<CephfsSubvolume[]>;
snapshots$: Observable<any[]>;
constructor(
private cephfsSubvolumeGroupService: CephfsSubvolumeGroupService,
- private cephfsSubvolumeService: CephfsSubvolumeService
- ) {}
+ private cephfsSubvolumeService: CephfsSubvolumeService,
+ private actionLabels: ActionLabelsI18n,
+ private modalService: ModalService,
+ private authStorageService: AuthStorageService,
+ private cdDatePipe: CdDatePipe
+ ) {
+ this.permissions = this.authStorageService.getPermissions();
+ }
ngOnInit(): void {
this.columns = [
name: $localize`Created`,
prop: 'info.created_at',
flexGrow: 1,
- cellTransformation: CellTemplate.timeAgo
+ pipe: this.cdDatePipe
},
{
name: $localize`Pending Clones`,
}
];
+ this.tableActions = [
+ {
+ name: this.actionLabels.CREATE,
+ permission: 'create',
+ icon: Icons.add,
+ click: () => this.openModal()
+ }
+ ];
+
this.cephfsSubvolumeGroupService
.get(this.fsName)
.pipe(
fetchData() {
this.snapshotSubject.next([]);
}
+
+ openModal(edit = false) {
+ this.modalService.show(
+ CephfsSubvolumeSnapshotsFormComponent,
+ {
+ fsName: this.fsName,
+ subVolumeName: this.activeSubVolumeName,
+ subVolumeGroupName: this.activeGroupName,
+ subVolumeGroups: this.subvolumeGroupList,
+ isEdit: edit
+ },
+ { size: 'lg' }
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
}
import { CephfsSubvolumeSnapshotsListComponent } from './cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component';
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';
@NgModule({
imports: [
CephfsSubvolumeGroupComponent,
CephfsSubvolumegroupFormComponent,
CephfsSubvolumeSnapshotsListComponent,
- CephfsSnapshotscheduleListComponent
+ CephfsSnapshotscheduleListComponent,
+ CephfsSubvolumeSnapshotsFormComponent
]
})
export class CephfsModule {}
);
expect(req.request.method).toBe('GET');
});
+
+ it('should call createSnapshot', () => {
+ service.createSnapshot('testFS', 'testSnap', 'testSubvol').subscribe();
+ const req = httpTesting.expectOne('api/cephfs/subvolume/snapshot/');
+ expect(req.request.method).toBe('POST');
+ });
});
}
);
}
+
+ getSnapshotInfo(snapshotName: string, fsName: string, subVolumeName: string, groupName = '') {
+ return this.http.get(`${this.baseURL}/snapshot/${fsName}/${subVolumeName}/info`, {
+ params: {
+ snap_name: snapshotName,
+ group_name: groupName
+ }
+ });
+ }
+
+ snapshotExists(
+ fsName: string,
+ snapshotName: string,
+ subVolumeName: string,
+ groupName: string = ''
+ ): Observable<boolean> {
+ return this.getSnapshotInfo(fsName, snapshotName, subVolumeName, groupName).pipe(
+ mapTo(true),
+ catchError((error: Event) => {
+ if (_.isFunction(error.preventDefault)) {
+ error.preventDefault();
+ }
+ return of(false);
+ })
+ );
+ }
+
+ createSnapshot(
+ fsName: string,
+ snapshotName: string,
+ subVolumeName: string,
+ groupName: string = ''
+ ) {
+ return this.http.post(
+ `${this.baseURL}/snapshot/`,
+ {
+ vol_name: fsName,
+ subvol_name: subVolumeName,
+ snap_name: snapshotName,
+ group_name: groupName
+ },
+ { observe: 'response' }
+ );
+ }
}
return value == null || value.length === 0;
}
-export type existsServiceFn = (value: any, args?: any) => Observable<boolean>;
+export type existsServiceFn = (value: any, ...args: any[]) => Observable<boolean>;
export class CdValidators {
/**
serviceFnThis: any = null,
usernameFn?: Function,
uidField = false,
- extraArgs = ''
+ ...extraArgs: any[]
): AsyncValidatorFn {
let uName: string;
return (control: AbstractControl): Observable<ValidationErrors | null> => {
}
return observableTimer().pipe(
- switchMapTo(serviceFn.call(serviceFnThis, uName, extraArgs)),
+ switchMapTo(serviceFn.call(serviceFnThis, uName, ...extraArgs)),
map((resp: boolean) => {
if (!resp) {
return null;
-import { DatePipe } from '@angular/common';
-
import moment from 'moment';
import { CdDatePipe } from './cd-date.pipe';
describe('CdDatePipe', () => {
- const datePipe = new DatePipe('en-US');
- let pipe = new CdDatePipe(datePipe);
+ let pipe = new CdDatePipe();
it('create an instance', () => {
- pipe = new CdDatePipe(datePipe);
+ pipe = new CdDatePipe();
expect(pipe).toBeTruthy();
});
});
it('transforms with some date', () => {
- const result = moment(1527085564486).format('M/D/YY LTS');
+ const result = moment
+ .parseZone(moment.unix(1527085564486))
+ .utc()
+ .utcOffset(moment().utcOffset())
+ .local()
+ .format('D/M/YY hh:mm A');
expect(pipe.transform(1527085564486)).toBe(result);
});
});
-import { DatePipe } from '@angular/common';
import { Pipe, PipeTransform } from '@angular/core';
+import _ from 'lodash';
+import moment from 'moment';
@Pipe({
name: 'cdDate'
})
export class CdDatePipe implements PipeTransform {
- constructor(private datePipe: DatePipe) {}
+ constructor() {}
transform(value: any): any {
if (value === null || value === '') {
return '';
}
- return (
- this.datePipe.transform(value, 'shortDate') +
- ' ' +
- this.datePipe.transform(value, 'mediumTime')
- );
+ let date: string;
+ const offset = moment().utcOffset();
+ if (_.isNumber(value)) {
+ date = moment
+ .parseZone(moment.unix(value))
+ .utc()
+ .utcOffset(offset)
+ .local()
+ .format('D/M/YY hh:mm A');
+ } else {
+ value = value?.replace('Z', '');
+ date = moment.parseZone(value).utc().utcOffset(offset).local().format('D/M/YY hh:mm A');
+ }
+ return date;
}
}
),
'cephfs/subvolume/group/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
this.subvolumegroup(metadata)
+ ),
+ 'cephfs/subvolume/snapshot/create': this.newTaskMessage(
+ this.commonOperations.create,
+ (metadata) => this.snapshot(metadata)
)
};
return $localize`subvolume group '${metadata.subvolumegroupName}'`;
}
+ snapshot(metadata: any) {
+ return $localize`snapshot '${metadata.snapshotName}'`;
+ }
+
crudMessageId(id: string) {
return $localize`${id}`;
}
- jwt: []
tags:
- CephfsSubvolumeGroup
+ /api/cephfs/subvolume/snapshot:
+ post:
+ parameters: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ group_name:
+ default: ''
+ type: string
+ snap_name:
+ type: string
+ subvol_name:
+ type: string
+ vol_name:
+ type: string
+ required:
+ - vol_name
+ - subvol_name
+ - snap_name
+ 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:
+ - CephfsSubvolumeSnapshot
/api/cephfs/subvolume/snapshot/{vol_name}/{subvol_name}:
get:
parameters:
- jwt: []
tags:
- CephfsSubvolumeSnapshot
+ /api/cephfs/subvolume/snapshot/{vol_name}/{subvol_name}/info:
+ get:
+ parameters:
+ - in: path
+ name: vol_name
+ required: true
+ schema:
+ type: string
+ - in: path
+ name: subvol_name
+ required: true
+ schema:
+ type: string
+ - in: query
+ name: snap_name
+ required: true
+ schema:
+ type: string
+ - default: ''
+ in: query
+ name: group_name
+ schema:
+ type: string
+ responses:
+ '200':
+ content:
+ application/vnd.ceph.api.v1.0+json:
+ type: object
+ description: OK
+ '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:
+ - CephfsSubvolumeSnapshot
/api/cephfs/subvolume/{vol_name}:
delete:
parameters: