]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: provide ability to edit cephfs subvolume 52861/head
authorNizamudeen A <nia@redhat.com>
Mon, 7 Aug 2023 12:56:09 +0000 (18:26 +0530)
committerNizamudeen A <nia@redhat.com>
Fri, 11 Aug 2023 18:56:37 +0000 (00:26 +0530)
Fixes: https://tracker.ceph.com/issues/62347
Signed-off-by: Nizamudeen A <nia@redhat.com>
15 files changed:
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-form/cephfs-subvolume-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-form/cephfs-subvolume-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-form/cephfs-subvolume-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-list/cephfs-subvolume-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-list/cephfs-subvolume-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/checked-table-form/checked-table-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/checked-table-form/checked-table-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-subvolume.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/octal-to-human-readable.pipe.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/openapi.yaml

index 363bef8d86033f70a2072c0c57e27a60914b5f47..0c845009ad262fb1ec8d526348017be7232e67bb 100644 (file)
@@ -744,3 +744,14 @@ class CephFSSubvolumeGroups(RESTController):
             raise DashboardException(
                 f'Failed to create subvolume group {group_name}: {err}'
             )
+
+    def set(self, vol_name: str, subvol_name: str, size: str):
+        if size:
+            error_code, _, err = mgr.remote('volumes', '_cmd_fs_subvolume_resize', None, {
+                'vol_name': vol_name, 'sub_name': subvol_name, 'new_size': size})
+            if error_code != 0:
+                raise DashboardException(
+                    f'Failed to update subvolume {subvol_name}: {err}'
+                )
+
+        return f'Subvolume {subvol_name} updated successfully'
index 4e3bb68014829027a6f3e96ffd233ebda7572b61..336e07cdb370200d9f0a14dbf43ce1f9e52f0880 100644 (file)
@@ -2,7 +2,8 @@
   <ng-container i18n="form title"
                 class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
 
-  <ng-container class="modal-content">
+  <ng-container class="modal-content"
+                *cdFormLoading="loading">
     <form name="subvolumeForm"
           #formDir="ngForm"
           [formGroup]="subvolumeForm"
@@ -47,7 +48,8 @@
           <label class="cd-col-form-label"
                  for="size"
                  i18n>Size
-          <cd-helper>The size of the subvolume is specified by setting a quota on it</cd-helper>
+          <cd-helper>The size of the subvolume is specified by setting a quota on it.
+            If left blank or put 0, then quota will be infinite</cd-helper>
           </label>
           <div class="cd-col-form-input">
             <input class="form-control"
                                    [form]="subvolumeForm"
                                    inputField="mode"
                                    [isTableForOctalMode]="true"
-                                   [scopes]="scopes"></cd-checked-table-form>
+                                   [initialValue]="initialMode"
+                                   [scopes]="scopes"
+                                   [isDisabled]="isEdit"></cd-checked-table-form>
           </div>
           </div>
 
index 9407290f31688aeae9a2cb740d9fc7a9aaa4e91d..392e5c54ac757490a66ed6b8fd934683898d68a6 100644 (file)
@@ -7,10 +7,15 @@ 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 } from '~/testing/unit-test-helper';
+import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
 
 describe('CephfsSubvolumeFormComponent', () => {
   let component: CephfsSubvolumeFormComponent;
   let fixture: ComponentFixture<CephfsSubvolumeFormComponent>;
+  let formHelper: FormHelper;
+  let createSubVolumeSpy: jasmine.Spy;
+  let editSubVolumeSpy: jasmine.Spy;
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
@@ -29,11 +34,46 @@ describe('CephfsSubvolumeFormComponent', () => {
   beforeEach(() => {
     fixture = TestBed.createComponent(CephfsSubvolumeFormComponent);
     component = fixture.componentInstance;
+    component.fsName = 'test_volume';
     component.pools = [];
+    component.ngOnInit();
+    formHelper = new FormHelper(component.subvolumeForm);
+    createSubVolumeSpy = spyOn(TestBed.inject(CephfsSubvolumeService), 'create').and.stub();
+    editSubVolumeSpy = spyOn(TestBed.inject(CephfsSubvolumeService), 'update').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 have the volume name prefilled', () => {
+    component.ngOnInit();
+    expect(component.subvolumeForm.get('volumeName').value).toBe('test_volume');
+  });
+
+  it('should submit the form', () => {
+    formHelper.setValue('subvolumeName', 'test_subvolume');
+    formHelper.setValue('size', 10);
+    component.submit();
+
+    expect(createSubVolumeSpy).toHaveBeenCalled();
+    expect(editSubVolumeSpy).not.toHaveBeenCalled();
+  });
+
+  it('should edit the subvolume', () => {
+    component.isEdit = true;
+    component.ngOnInit();
+    formHelper.setValue('subvolumeName', 'test_subvolume');
+    formHelper.setValue('size', 10);
+    component.submit();
+
+    expect(editSubVolumeSpy).toHaveBeenCalled();
+    expect(createSubVolumeSpy).not.toHaveBeenCalled();
+  });
 });
index 09d52dabe3b740a81ce5d9258e87f5a36355d11e..5ffb29bdce80da76f913a1e4287d3308a01afbd9 100644 (file)
@@ -11,15 +11,21 @@ import { FormatterService } from '~/app/shared/services/formatter.service';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
 import _ from 'lodash';
 import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CephfsSubvolumeInfo } from '~/app/shared/models/cephfs-subvolume.model';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { OctalToHumanReadablePipe } from '~/app/shared/pipes/octal-to-human-readable.pipe';
+import { CdForm } from '~/app/shared/forms/cd-form';
 
 @Component({
   selector: 'cd-cephfs-subvolume-form',
   templateUrl: './cephfs-subvolume-form.component.html',
   styleUrls: ['./cephfs-subvolume-form.component.scss']
 })
-export class CephfsSubvolumeFormComponent implements OnInit {
+export class CephfsSubvolumeFormComponent extends CdForm implements OnInit {
   fsName: string;
+  subVolumeName: string;
   pools: Pool[];
+  isEdit = false;
 
   subvolumeForm: CdFormGroup;
 
@@ -30,6 +36,11 @@ export class CephfsSubvolumeFormComponent implements OnInit {
 
   columns: CdTableColumn[];
   scopePermissions: Array<any> = [];
+  initialMode = {
+    owner: ['read', 'write', 'execute'],
+    group: ['read', 'execute'],
+    others: ['read', 'execute']
+  };
   scopes: string[] = ['owner', 'group', 'others'];
 
   constructor(
@@ -37,13 +48,16 @@ export class CephfsSubvolumeFormComponent implements OnInit {
     private actionLabels: ActionLabelsI18n,
     private taskWrapper: TaskWrapperService,
     private cephFsSubvolumeService: CephfsSubvolumeService,
-    private formatter: FormatterService
+    private formatter: FormatterService,
+    private dimlessBinary: DimlessBinaryPipe,
+    private octalToHumanReadable: OctalToHumanReadablePipe
   ) {
-    this.action = this.actionLabels.CREATE;
+    super();
     this.resource = $localize`Subvolume`;
   }
 
   ngOnInit(): void {
+    this.action = this.actionLabels.CREATE;
     this.columns = [
       {
         prop: 'scope',
@@ -72,6 +86,8 @@ export class CephfsSubvolumeFormComponent implements OnInit {
 
     this.dataPools = this.pools.filter((pool) => pool.type === 'data');
     this.createForm();
+
+    this.isEdit ? this.populateForm() : this.loadingReady();
   }
 
   createForm() {
@@ -102,37 +118,82 @@ export class CephfsSubvolumeFormComponent implements OnInit {
     });
   }
 
+  populateForm() {
+    this.action = this.actionLabels.EDIT;
+    this.cephFsSubvolumeService
+      .info(this.fsName, this.subVolumeName)
+      .subscribe((resp: CephfsSubvolumeInfo) => {
+        // Disabled these fields since its not editable
+        this.subvolumeForm.get('subvolumeName').disable();
+        this.subvolumeForm.get('pool').disable();
+        this.subvolumeForm.get('uid').disable();
+        this.subvolumeForm.get('gid').disable();
+
+        this.subvolumeForm.get('isolatedNamespace').disable();
+        this.subvolumeForm.get('subvolumeName').setValue(this.subVolumeName);
+        if (resp.bytes_quota !== 'infinite') {
+          this.subvolumeForm.get('size').setValue(this.dimlessBinary.transform(resp.bytes_quota));
+        }
+        this.subvolumeForm.get('uid').setValue(resp.uid);
+        this.subvolumeForm.get('gid').setValue(resp.gid);
+        this.subvolumeForm.get('isolatedNamespace').setValue(resp.pool_namespace);
+        this.initialMode = this.octalToHumanReadable.transform(resp.mode, true);
+
+        this.loadingReady();
+      });
+  }
+
   submit() {
     const subVolumeName = this.subvolumeForm.getValue('subvolumeName');
     const pool = this.subvolumeForm.getValue('pool');
-    const size = this.formatter.toBytes(this.subvolumeForm.getValue('size'));
+    const size = this.formatter.toBytes(this.subvolumeForm.getValue('size')) || 0;
     const uid = this.subvolumeForm.getValue('uid');
     const gid = this.subvolumeForm.getValue('gid');
     const mode = this.formatter.toOctalPermission(this.subvolumeForm.getValue('mode'));
     const isolatedNamespace = this.subvolumeForm.getValue('isolatedNamespace');
-    this.taskWrapper
-      .wrapTaskAroundCall({
-        task: new FinishedTask('cephfs/subvolume/' + URLVerbs.CREATE, {
-          subVolumeName: subVolumeName
-        }),
-        call: this.cephFsSubvolumeService.create(
-          this.fsName,
-          subVolumeName,
-          pool,
-          size,
-          uid,
-          gid,
-          mode,
-          isolatedNamespace
-        )
-      })
-      .subscribe({
-        error: () => {
-          this.subvolumeForm.setErrors({ cdSubmitButton: true });
-        },
-        complete: () => {
-          this.activeModal.close();
-        }
-      });
+
+    if (this.isEdit) {
+      const editSize = size === 0 ? 'infinite' : size;
+      this.taskWrapper
+        .wrapTaskAroundCall({
+          task: new FinishedTask('cephfs/subvolume/' + URLVerbs.EDIT, {
+            subVolumeName: subVolumeName
+          }),
+          call: this.cephFsSubvolumeService.update(this.fsName, subVolumeName, String(editSize))
+        })
+        .subscribe({
+          error: () => {
+            this.subvolumeForm.setErrors({ cdSubmitButton: true });
+          },
+          complete: () => {
+            this.activeModal.close();
+          }
+        });
+    } else {
+      this.taskWrapper
+        .wrapTaskAroundCall({
+          task: new FinishedTask('cephfs/subvolume/' + URLVerbs.CREATE, {
+            subVolumeName: subVolumeName
+          }),
+          call: this.cephFsSubvolumeService.create(
+            this.fsName,
+            subVolumeName,
+            pool,
+            String(size),
+            uid,
+            gid,
+            mode,
+            isolatedNamespace
+          )
+        })
+        .subscribe({
+          error: () => {
+            this.subvolumeForm.setErrors({ cdSubmitButton: true });
+          },
+          complete: () => {
+            this.activeModal.close();
+          }
+        });
+    }
   }
 }
index 53aa454e4cb6a6cc91c449c763bd01128bc8500c..94922c661e27c0161cf93f5debb6ea35772c2ee4 100644 (file)
@@ -4,7 +4,8 @@
             [columns]="columns"
             selectionType="single"
             [hasDetails]="false"
-            (fetchData)="fetchData()">
+            (fetchData)="fetchData()"
+            (updateSelection)="updateSelection($event)">
 
     <div class="table-actions btn-toolbar">
       <cd-table-actions [permission]="permissions.cephfs"
@@ -21,9 +22,9 @@
              let-row="row">
   <cd-usage-bar *ngIf="row.info.bytes_pcent && row.info.bytes_pcent !== 'undefined'; else noLimitTpl"
                 [total]="row.info.bytes_quota"
-                [used]="row.info.bytes_pcent"
+                [used]="row.info.bytes_used"
                 [title]="row.name"
-                [calculatePerc]="false"
+                [showFreeToolTip]="false"
                 customLegend="Quota"
                 [customLegendValue]="row.info.bytes_quota"
                 decimals="2"></cd-usage-bar>
index 14c0ea724da604eb596bceac3055926b35a06dad..47953b34c89fe84bccffeef5610aaab0c17e3388 100644 (file)
@@ -107,15 +107,14 @@ export class CephfsSubvolumeListComponent implements OnInit, OnChanges {
         name: this.actionLabels.CREATE,
         permission: 'create',
         icon: Icons.add,
-        click: () =>
-          this.modalService.show(
-            CephfsSubvolumeFormComponent,
-            {
-              fsName: this.fsName,
-              pools: this.pools
-            },
-            { size: 'lg' }
-          )
+        click: () => this.openModal(),
+        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+      },
+      {
+        name: this.actionLabels.EDIT,
+        permission: 'update',
+        icon: Icons.edit,
+        click: () => this.openModal(true)
       }
     ];
 
@@ -143,4 +142,17 @@ export class CephfsSubvolumeListComponent implements OnInit, OnChanges {
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
+
+  openModal(edit = false) {
+    this.modalService.show(
+      CephfsSubvolumeFormComponent,
+      {
+        fsName: this.fsName,
+        subVolumeName: this.selection?.first()?.name,
+        pools: this.pools,
+        isEdit: edit
+      },
+      { size: 'lg' }
+    );
+  }
 }
index bd9a16e0e333baf5b5699e2636c947dc9bfcaeb7..ca7bf095891c24173e5db34a8fb0a290acd9fdd5 100644 (file)
@@ -21,7 +21,7 @@ export class CephfsSubvolumeService {
     fsName: string,
     subVolumeName: string,
     poolName: string,
-    size: number,
+    size: string,
     uid: number,
     gid: number,
     mode: string,
@@ -62,4 +62,11 @@ export class CephfsSubvolumeService {
       })
     );
   }
+
+  update(fsName: string, subVolumeName: string, size: string) {
+    return this.http.put(`${this.baseURL}/${fsName}`, {
+      subvol_name: subVolumeName,
+      size: size
+    });
+  }
 }
index 10064941f75d6a594c33cea7658f49e4543b2071..70020436edecf6878ee77c7ce3dc02916b475011 100644 (file)
@@ -4,7 +4,7 @@
       <td class="text-left me-1">Used:</td>
       <td class="text-right"><strong> {{ isBinary ? (used | dimlessBinary) : (used | dimless) }}</strong></td>
     </tr>
-    <tr *ngIf="calculatePerc">
+    <tr *ngIf="calculatePerc && showFreeToolTip">
       <td class="text-left me-1">Free:</td>
       <td class="'text-right"><strong>{{ isBinary ? (total - used | dimlessBinary) : (total - used | dimless) }}</strong></td>
     </tr>
index e41ed4c31095720288a1c319d6559b7a3fbf830b..4940c19061ba32e768d1492d63f2ffd883d38fd2 100644 (file)
@@ -28,6 +28,8 @@ export class UsageBarComponent implements OnChanges {
   customLegend?: string;
   @Input()
   customLegendValue?: string;
+  @Input()
+  showFreeToolTip = true;
 
   usedPercentage: number;
   freePercentage: number;
index 7d96239e93d015254628cf7642879dcc43f0dac8..dae4985d943164adcc77a109b3941873fcf27c6e 100644 (file)
@@ -17,6 +17,7 @@
            id="scope_{{ row.scope }}"
            type="checkbox"
            [checked]="isRowChecked(row.scope)"
+           [disabled]="isDisabled"
            (change)="onClickCellCheckbox(row.scope, column.prop, $event)">
     <label class="datatable-permissions-scope-cell-label custom-control-label"
            for="scope_{{ row.scope }}">{{ value }}</label>
@@ -31,6 +32,7 @@
     <input class="custom-control-input"
            type="checkbox"
            [checked]="value"
+           [disabled]="isDisabled"
            [id]="row.scope + '-' + column.prop"
            (change)="onClickCellCheckbox(row.scope, column.prop, $event)">
     <label class="custom-control-label"
@@ -44,6 +46,7 @@
     <input class="custom-control-input"
            id="header_{{ column.prop }}"
            type="checkbox"
+           [disabled]="isDisabled"
            [checked]="isHeaderChecked(column.prop)"
            (change)="onClickHeaderCheckbox(column.prop, $event)">
     <label class="datatable-permissions-header-cell-label custom-control-label"
index 27ff4bbc65005917cb434ca28366764966876be5..743b0fd2de2c32d0db331643547217d256e49beb 100644 (file)
@@ -15,11 +15,8 @@ export class CheckedTableFormComponent implements OnInit {
   @Input() inputField: string;
   @Input() scopes: Array<string> = [];
   @Input() isTableForOctalMode = false;
-  @Input() initialValue: any = {
-    owner: ['read', 'write', 'execute'],
-    group: ['read', 'execute'],
-    others: ['read', 'execute']
-  };
+  @Input() initialValue = {};
+  @Input() isDisabled = false;
 
   @ViewChild('headerPermissionCheckboxTpl', { static: true })
   headerPermissionCheckboxTpl: TemplateRef<any>;
index bceaf311d50878bb6541c147410fc469f8c3c372..41858be613042d09d22819fc52e34c2bc839e6ce 100644 (file)
@@ -6,10 +6,13 @@ export interface CephfsSubvolume {
 export interface CephfsSubvolumeInfo {
   mode: number;
   type: string;
-  bytes_pcent: number;
-  bytes_quota: number;
+  bytes_pcent: string;
+  bytes_quota: string;
   data_pool: string;
   path: string;
   state: string;
   created_at: string;
+  uid: number;
+  gid: number;
+  pool_namespace: string;
 }
index d8455630597b1e2d38c98df5ca0124f1dc564349..54e72792126fe85a05eae345f2781962a52be29f 100644 (file)
@@ -4,7 +4,7 @@ import { Pipe, PipeTransform } from '@angular/core';
   name: 'octalToHumanReadable'
 })
 export class OctalToHumanReadablePipe implements PipeTransform {
-  transform(value: number): any {
+  transform(value: number, toTableArray = false): any {
     if (!value) {
       return [];
     }
@@ -18,6 +18,14 @@ export class OctalToHumanReadablePipe implements PipeTransform {
     const group = permissions[parseInt(digits[5])];
     const others = permissions[parseInt(digits[6])];
 
+    if (toTableArray) {
+      return {
+        owner: this.getItem(owner),
+        group: this.getItem(group),
+        others: this.getItem(others)
+      };
+    }
+
     if (fileType !== 'directory') {
       permissionSummary.push({
         content: fileType,
@@ -77,4 +85,12 @@ export class OctalToHumanReadablePipe implements PipeTransform {
         return '-';
     }
   }
+
+  private getItem(item: string) {
+    const returnlist = [];
+    if (item.includes('r')) returnlist.push('read');
+    if (item.includes('w')) returnlist.push('write');
+    if (item.includes('x')) returnlist.push('execute');
+    return returnlist;
+  }
 }
index 607dd743a8a9d8df869e80fba5b3598baa435b00..994adf2dcd8e5c21e755ee73465359eb3b2bb16b 100644 (file)
@@ -356,14 +356,17 @@ export class TaskMessageService {
     'cephfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
       this.volume(metadata)
     ),
+    'cephfs/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
+      this.volume(metadata)
+    ),
     'cephfs/subvolume/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
       this.subvolume(metadata)
     ),
+    'cephfs/subvolume/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
+      this.subvolume(metadata)
+    ),
     'cephfs/subvolume/group/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
       this.subvolumegroup(metadata)
-    ),
-    'cephfs/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
-      this.volume(metadata)
     )
   };
 
index efc1690da8caf94c29d2dbd3bb5524436817533b..86743ab1c4c73dfdaa980c95cf32a2f73d36f8bf 100644 (file)
@@ -1865,6 +1865,50 @@ paths:
       - jwt: []
       tags:
       - CephfsSubvolumeGroup
+    put:
+      parameters:
+      - in: path
+        name: vol_name
+        required: true
+        schema:
+          type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                size:
+                  type: integer
+                subvol_name:
+                  type: string
+              required:
+              - subvol_name
+              - size
+              type: object
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource updated.
+        '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:
+      - CephfsSubvolumeGroup
   /api/cephfs/subvolume/{vol_name}:
     get:
       parameters: