]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: CephFS quota management
authorStephan Müller <smueller@suse.com>
Wed, 6 Nov 2019 16:47:47 +0000 (17:47 +0100)
committerStephan Müller <smueller@suse.com>
Fri, 13 Dec 2019 14:15:34 +0000 (15:15 +0100)
Now both CephFS quotas can be changed with a validation against the next
tree maximum in the file tree, that prevents setting the quotas in a way
that would not be usable.

Fixes: https://tracker.ceph.com/issues/38287
Signed-off-by: Stephan Müller <smueller@suse.com>
12 files changed:
qa/tasks/mgr/dashboard/test_cephfs.py
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts
src/pybind/mgr/dashboard/services/cephfs.py

index 47a39d18bf6f1110409aa26b68b494c81c50b86c..bfd274025196b4e0bac32b90dd9ed0dc4140efb3 100644 (file)
@@ -2,6 +2,7 @@
 from __future__ import absolute_import
 
 import six
+from contextlib import contextmanager
 
 from .helper import DashboardTestCase, JObj, JList, JLeaf
 
@@ -11,6 +12,8 @@ class CephfsTest(DashboardTestCase):
 
     AUTH_ROLES = ['cephfs-manager']
 
+    QUOTA_PATH = '/quotas'
+
     def assertToHave(self, data, key):
         self.assertIn(key, data)
         self.assertIsNotNone(data[key])
@@ -39,6 +42,26 @@ class CephfsTest(DashboardTestCase):
         self.assertEqual(len(data), expectedLength)
         return data
 
+    def setQuotas(self, bytes=None, files=None):
+        quotas = {
+            'max_bytes': bytes,
+            'max_files': files
+        }
+        self._post("/api/cephfs/{}/set_quotas".format(self.get_fs_id()), data=quotas,
+                   params={'path': self.QUOTA_PATH})
+        self.assertStatus(200)
+
+    def assertQuotas(self, bytes, files):
+        data = self.ls_dir('/', 1)[0]
+        self.assertEqual(data['quotas']['max_bytes'], bytes)
+        self.assertEqual(data['quotas']['max_files'], files)
+
+    @contextmanager
+    def new_quota_dir(self):
+        self.mk_dirs(self.QUOTA_PATH)
+        self.setQuotas(1024**3, 1024)
+        yield 1
+        self.rm_dir(self.QUOTA_PATH)
 
     @DashboardTestCase.RunAs('test', 'test', ['block-manager'])
     def test_access_permissions(self):
@@ -199,3 +222,28 @@ class CephfsTest(DashboardTestCase):
         self.rm_dir('/movies/dune/extended_version')
         self.rm_dir('/movies/dune')
         self.rm_dir('/movies')
+
+    def test_quotas_default(self):
+        self.mk_dirs(self.QUOTA_PATH)
+        self.assertQuotas(0, 0)
+        self.rm_dir(self.QUOTA_PATH)
+
+    def test_quotas_set_both(self):
+        with self.new_quota_dir():
+            self.assertQuotas(1024**3, 1024)
+
+    def test_quotas_set_only_bytes(self):
+        with self.new_quota_dir():
+            self.setQuotas(2048**3)
+            self.assertQuotas(2048**3, 1024)
+
+    def test_quotas_set_only_files(self):
+        with self.new_quota_dir():
+            self.setQuotas(None, 2048)
+            self.assertQuotas(1024**3, 2048)
+
+    def test_quotas_unset_both(self):
+        with self.new_quota_dir():
+            self.setQuotas(0, 0)
+            self.assertQuotas(0, 0)
+
index 84d754199d0a627c074cd689000c1e594f34dfdc..7678e45a50cd36d488a306a0ccbc6ed484cf1d05 100644 (file)
@@ -414,7 +414,7 @@ class CephFS(RESTController):
         return cfs.get_quotas(path)
 
     @RESTController.Resource('POST')
-    def set_quotas(self, fs_id, path, max_bytes, max_files):
+    def set_quotas(self, fs_id, path, max_bytes=None, max_files=None):
         """
         Set the quotas of the specified path.
         :param fs_id: The filesystem identifier.
index 80d7a6005a7193533c74578c414565df0cebe1da..8a6e0f176fe71f122fe11a475de95c736ac8114c 100644 (file)
       <div class="card-body">
         <legend i18n>Quotas</legend>
         <cd-table [data]="settings"
-                  [columns]="settingsColumns"
+                  [columns]="quota.columns"
                   [limit]="0"
                   [footer]="false"
+                  selectionType="single"
+                  (updateSelection)="quota.updateSelection($event)"
+                  [onlyActionHeader]="true"
+                  identifier="quotaKey"
+                  [forceIdentifier]="true"
                   [toolHeader]="false">
+          <cd-table-actions class="only-table-actions"
+                            [permission]="permission"
+                            [selection]="quota.selection"
+                            [tableActions]="quota.tableActions">
+          </cd-table-actions>
         </cd-table>
 
         <legend i18n>Snapshots</legend>
index 7f3eba70e290956464b2af809d6457cf0e45649a..ed277400548e42a375c6f8dda2a372501cafff89 100644 (file)
@@ -1,5 +1,6 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Validators } from '@angular/forms';
 import { RouterTestingModule } from '@angular/router/testing';
 
 import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation';
@@ -15,11 +16,18 @@ import {
   PermissionHelper
 } from '../../../../testing/unit-test-helper';
 import { CephfsService } from '../../../shared/api/cephfs.service';
+import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
+import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component';
+import { NotificationType } from '../../../shared/enum/notification-type.enum';
+import { CdValidators } from '../../../shared/forms/cd-validators';
+import { CdTableAction } from '../../../shared/models/cd-table-action';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
 import {
   CephfsDir,
   CephfsQuotas,
   CephfsSnapshot
 } from '../../../shared/models/cephfs-directory-models';
+import { NotificationService } from '../../../shared/services/notification.service';
 import { SharedModule } from '../../../shared/shared.module';
 import { CephfsDirectoriesComponent } from './cephfs-directories.component';
 
@@ -28,6 +36,12 @@ describe('CephfsDirectoriesComponent', () => {
   let fixture: ComponentFixture<CephfsDirectoriesComponent>;
   let cephfsService: CephfsService;
   let lsDirSpy;
+  let modalShowSpy;
+  let notificationShowSpy;
+  let minValidator;
+  let maxValidator;
+  let minBinaryValidator;
+  let maxBinaryValidator;
   let originalDate;
   let modal;
 
@@ -44,6 +58,7 @@ describe('CephfsDirectoriesComponent', () => {
     parent: Tree;
     createdSnaps: CephfsSnapshot[] | any[];
     deletedSnaps: CephfsSnapshot[] | any[];
+    updatedQuotas: { [path: string]: CephfsQuotas };
   };
 
   // Object contains mock functions
@@ -62,9 +77,9 @@ describe('CephfsDirectoriesComponent', () => {
       }
       return snapshots;
     },
-    dir: (path: string, name: string, modifier: number): CephfsDir => {
-      const dirPath = `${path === '/' ? '' : path}/${name}`;
-      let snapshots = mockLib.snapshots(path, modifier);
+    dir: (parentPath: string, name: string, modifier: number): CephfsDir => {
+      const dirPath = `${parentPath === '/' ? '' : parentPath}/${name}`;
+      let snapshots = mockLib.snapshots(parentPath, modifier);
       const extraSnapshots = mockData.createdSnaps.filter((s) => s.path === dirPath);
       if (extraSnapshots.length > 0) {
         snapshots = snapshots.concat(extraSnapshots);
@@ -78,8 +93,11 @@ describe('CephfsDirectoriesComponent', () => {
       return {
         name,
         path: dirPath,
-        parent: path,
-        quotas: mockLib.quotas(1024 * modifier, 10 * modifier),
+        parent: parentPath,
+        quotas: Object.assign(
+          mockLib.quotas(1024 * modifier, 10 * modifier),
+          mockData.updatedQuotas[dirPath] || {}
+        ),
         snapshots: snapshots
       };
     },
@@ -121,6 +139,10 @@ describe('CephfsDirectoriesComponent', () => {
       });
       return of(name);
     },
+    updateQuota: (_id, path, updated: CephfsQuotas) => {
+      mockData.updatedQuotas[path] = Object.assign(mockData.updatedQuotas[path] || {}, updated);
+      return of('Response');
+    },
     modalShow: (comp, init) => {
       modal = modalServiceShow(comp, init);
       return modal.ref;
@@ -175,6 +197,43 @@ describe('CephfsDirectoriesComponent', () => {
       component.snapshot.selection.selected = snapshots;
       component.deleteSnapshotModal();
       modal.component.callSubmitAction();
+    },
+    updateQuotaThroughModal: (attribute: string, value: number) => {
+      component.quota.selection.selected = component.settings.filter(
+        (q) => q.quotaKey === attribute
+      );
+      component.updateQuotaModal();
+      modal.component.onSubmitForm({ [attribute]: value });
+    },
+    unsetQuotaThroughModal: (attribute: string) => {
+      component.quota.selection.selected = component.settings.filter(
+        (q) => q.quotaKey === attribute
+      );
+      component.unsetQuotaModal();
+      modal.component.onSubmit();
+    },
+    setFourQuotaDirs: (quotas: number[][]) => {
+      expect(quotas.length).toBe(4); // Make sure this function is used correctly
+      let path = '';
+      quotas.forEach((quota, index) => {
+        index += 1;
+        mockLib.mkDir(path, index.toString(), quota[0], quota[1]);
+        path += '/' + index;
+      });
+      mockData.parent = {
+        value: '3',
+        id: '/1/2/3',
+        parent: {
+          value: '2',
+          id: '/1/2',
+          parent: {
+            value: '1',
+            id: '/1',
+            parent: { value: '/', id: '/' }
+          }
+        }
+      } as Tree;
+      mockLib.selectNode('/1/2/3/4');
     }
   };
 
@@ -186,16 +245,94 @@ describe('CephfsDirectoriesComponent', () => {
     requestedPaths: (expected: string[]) => expect(get.requestedPaths()).toEqual(expected),
     snapshotsByName: (snaps: string[]) =>
       expect(component.selectedDir.snapshots.map((s) => s.name)).toEqual(snaps),
-    quotaSettings: (
-      fileValue: number | string,
-      fileOrigin: string,
-      sizeValue: string,
-      sizeOrigin: string
-    ) =>
-      expect(component.settings).toEqual([
-        { name: 'Max files', value: fileValue, origin: fileOrigin },
-        { name: 'Max size', value: sizeValue, origin: sizeOrigin }
-      ])
+    dirQuotas: (bytes: number, files: number) => {
+      expect(component.selectedDir.quotas).toEqual({ max_bytes: bytes, max_files: files });
+    },
+    noQuota: (key: 'bytes' | 'files') => {
+      assert.quotaRow(key, '', 0, '');
+    },
+    quotaIsNotInherited: (key: 'bytes' | 'files', shownValue, nextMaximum) => {
+      const dir = component.selectedDir;
+      const path = dir.path;
+      assert.quotaRow(key, shownValue, nextMaximum, path);
+    },
+    quotaIsInherited: (key: 'bytes' | 'files', shownValue, path) => {
+      const isBytes = key === 'bytes';
+      const nextMaximum = get.nodeIds()[path].quotas[isBytes ? 'max_bytes' : 'max_files'];
+      assert.quotaRow(key, shownValue, nextMaximum, path);
+    },
+    quotaRow: (
+      key: 'bytes' | 'files',
+      shownValue: number | string,
+      nextTreeMaximum: number,
+      originPath: string
+    ) => {
+      const isBytes = key === 'bytes';
+      expect(component.settings[isBytes ? 1 : 0]).toEqual({
+        row: {
+          name: `Max ${isBytes ? 'size' : key}`,
+          value: shownValue,
+          originPath
+        },
+        quotaKey: `max_${key}`,
+        dirValue: expect.any(Number),
+        nextTreeMaximum: {
+          value: nextTreeMaximum,
+          path: expect.any(String)
+        }
+      });
+    },
+    quotaUnsetModalTexts: (titleText, message, notificationMsg) => {
+      expect(modalShowSpy).toHaveBeenCalledWith(ConfirmationModalComponent, {
+        initialState: expect.objectContaining({
+          titleText,
+          description: message,
+          buttonText: 'Unset'
+        })
+      });
+      expect(notificationShowSpy).toHaveBeenCalledWith(NotificationType.success, notificationMsg);
+    },
+    quotaUpdateModalTexts: (titleText, message, notificationMsg) => {
+      expect(modalShowSpy).toHaveBeenCalledWith(FormModalComponent, {
+        initialState: expect.objectContaining({
+          titleText,
+          message,
+          submitButtonText: 'Save'
+        })
+      });
+      expect(notificationShowSpy).toHaveBeenCalledWith(NotificationType.success, notificationMsg);
+    },
+    quotaUpdateModalField: (
+      type: string,
+      label: string,
+      key: string,
+      value: number,
+      max: number,
+      errors?: { [key: string]: string }
+    ) => {
+      expect(modalShowSpy).toHaveBeenCalledWith(FormModalComponent, {
+        initialState: expect.objectContaining({
+          fields: [
+            {
+              type,
+              label,
+              errors,
+              name: key,
+              value,
+              validators: expect.anything(),
+              required: true
+            }
+          ]
+        })
+      });
+      if (type === 'binary') {
+        expect(minBinaryValidator).toHaveBeenCalledWith(0);
+        expect(maxBinaryValidator).toHaveBeenCalledWith(max);
+      } else {
+        expect(minValidator).toHaveBeenCalledWith(0);
+        expect(maxValidator).toHaveBeenCalledWith(max);
+      }
+    }
   };
 
   configureTestBed({
@@ -217,7 +354,8 @@ describe('CephfsDirectoriesComponent', () => {
       nodes: undefined,
       parent: undefined,
       createdSnaps: [],
-      deletedSnaps: []
+      deletedSnaps: [],
+      updatedQuotas: {}
     };
     originalDate = Date;
     spyOn(global, 'Date').and.callFake(mockLib.date);
@@ -226,8 +364,10 @@ describe('CephfsDirectoriesComponent', () => {
     lsDirSpy = spyOn(cephfsService, 'lsDir').and.callFake(mockLib.lsDir);
     spyOn(cephfsService, 'mkSnapshot').and.callFake(mockLib.mkSnapshot);
     spyOn(cephfsService, 'rmSnapshot').and.callFake(mockLib.rmSnapshot);
+    spyOn(cephfsService, 'updateQuota').and.callFake(mockLib.updateQuota);
 
-    spyOn(TestBed.get(BsModalService), 'show').and.callFake(mockLib.modalShow);
+    modalShowSpy = spyOn(TestBed.get(BsModalService), 'show').and.callFake(mockLib.modalShow);
+    notificationShowSpy = spyOn(TestBed.get(NotificationService), 'show').and.stub();
 
     fixture = TestBed.createComponent(CephfsDirectoriesComponent);
     component = fixture.componentInstance;
@@ -292,6 +432,50 @@ describe('CephfsDirectoriesComponent', () => {
         '/a/a/b'
       ]);
     });
+
+    describe('test quota update mock', () => {
+      const PATH = '/a';
+      const ID = 2;
+
+      const updateQuota = (quotas: CephfsQuotas) => mockLib.updateQuota(ID, PATH, quotas);
+
+      const expectMockUpdate = (max_bytes?: number, max_files?: number) =>
+        expect(mockData.updatedQuotas[PATH]).toEqual({
+          max_bytes,
+          max_files
+        });
+
+      const expectLsUpdate = (max_bytes?: number, max_files?: number) => {
+        let dir: CephfsDir;
+        mockLib.lsDir(ID, '/').subscribe((dirs) => (dir = dirs.find((d) => d.path === PATH)));
+        expect(dir.quotas).toEqual({
+          max_bytes,
+          max_files
+        });
+      };
+
+      it('tests to set quotas', () => {
+        expectLsUpdate(1024, 10);
+
+        updateQuota({ max_bytes: 512 });
+        expectMockUpdate(512);
+        expectLsUpdate(512, 10);
+
+        updateQuota({ max_files: 100 });
+        expectMockUpdate(512, 100);
+        expectLsUpdate(512, 100);
+      });
+
+      it('tests to unset quotas', () => {
+        updateQuota({ max_files: 0 });
+        expectMockUpdate(undefined, 0);
+        expectLsUpdate(1024, 0);
+
+        updateQuota({ max_bytes: 0 });
+        expectMockUpdate(0, 0);
+        expectLsUpdate(0, 0);
+      });
+    });
   });
 
   it('calls lsDir only if an id exits', () => {
@@ -353,7 +537,8 @@ describe('CephfsDirectoriesComponent', () => {
       mockLib.selectNode('/a');
       const dir = get.dirs().find((d) => d.path === '/a');
       expect(component.selectedDir).toEqual(dir);
-      assert.quotaSettings(10, '/a', '1 KiB', '/a');
+      assert.quotaIsNotInherited('files', 10, 0);
+      assert.quotaIsNotInherited('bytes', '1 KiB', 0);
     });
 
     it('should extend the list by subdirectories when expanding and omit already called path', () => {
@@ -401,47 +586,32 @@ describe('CephfsDirectoriesComponent', () => {
     });
 
     describe('used quotas', () => {
-      const setUpDirs = (quotas: number[][]) => {
-        let path = '';
-        quotas.forEach((quota, index) => {
-          index += 1;
-          mockLib.mkDir(path, index.toString(), quota[0], quota[1]);
-          path += '/' + index;
-        });
-        mockData.parent = {
-          value: '3',
-          id: '/1/2/3',
-          parent: {
-            value: '2',
-            id: '/1/2',
-            parent: {
-              value: '1',
-              id: '/1',
-              parent: { value: '/', id: '/' }
-            }
-          }
-        } as Tree;
-        mockLib.selectNode('/1/2/3/4');
-      };
-
       it('should use no quota if none is set', () => {
-        setUpDirs([[0, 0], [0, 0], [0, 0], [0, 0]]);
-        assert.quotaSettings('', '', '', '');
+        mockLib.setFourQuotaDirs([[0, 0], [0, 0], [0, 0], [0, 0]]);
+        assert.noQuota('files');
+        assert.noQuota('bytes');
+        assert.dirQuotas(0, 0);
       });
 
       it('should use quota from upper parents', () => {
-        setUpDirs([[100, 0], [0, 8], [0, 0], [0, 0]]);
-        assert.quotaSettings(100, '/1', '8 KiB', '/1/2');
+        mockLib.setFourQuotaDirs([[100, 0], [0, 8], [0, 0], [0, 0]]);
+        assert.quotaIsInherited('files', 100, '/1');
+        assert.quotaIsInherited('bytes', '8 KiB', '/1/2');
+        assert.dirQuotas(0, 0);
       });
 
       it('should use quota from the parent with the lowest value (deep inheritance)', () => {
-        setUpDirs([[200, 1], [100, 4], [400, 3], [300, 2]]);
-        assert.quotaSettings(100, '/1/2', '1 KiB', '/1');
+        mockLib.setFourQuotaDirs([[200, 1], [100, 4], [400, 3], [300, 2]]);
+        assert.quotaIsInherited('files', 100, '/1/2');
+        assert.quotaIsInherited('bytes', '1 KiB', '/1');
+        assert.dirQuotas(2048, 300);
       });
 
       it('should use current value', () => {
-        setUpDirs([[200, 2], [300, 4], [400, 3], [100, 1]]);
-        assert.quotaSettings(100, '/1/2/3/4', '1 KiB', '/1/2/3/4');
+        mockLib.setFourQuotaDirs([[200, 2], [300, 4], [400, 3], [100, 1]]);
+        assert.quotaIsNotInherited('files', 100, 200);
+        assert.quotaIsNotInherited('bytes', '1 KiB', 2048);
+        assert.dirQuotas(1024, 100);
       });
     });
   });
@@ -469,11 +639,6 @@ describe('CephfsDirectoriesComponent', () => {
       mockLib.deleteSnapshotsThroughModal(component.selectedDir.snapshots);
       assert.snapshotsByName([]);
     });
-
-    afterEach(() => {
-      // Makes sure the directory is updated correctly
-      expect(component.selectedDir).toEqual(get.nodeIds()[component.selectedDir.path]);
-    });
   });
 
   it('should test all snapshot table actions combinations', () => {
@@ -517,4 +682,239 @@ describe('CephfsDirectoriesComponent', () => {
       }
     });
   });
+
+  describe('quotas', () => {
+    beforeEach(() => {
+      // Spies
+      minValidator = spyOn(Validators, 'min').and.callThrough();
+      maxValidator = spyOn(Validators, 'max').and.callThrough();
+      minBinaryValidator = spyOn(CdValidators, 'binaryMin').and.callThrough();
+      maxBinaryValidator = spyOn(CdValidators, 'binaryMax').and.callThrough();
+      // Select /a/c/b
+      mockLib.changeId(1);
+      mockLib.selectNode('/a');
+      mockLib.selectNode('/a/c');
+      mockLib.selectNode('/a/c/b');
+      // Quotas after selection
+      assert.quotaIsInherited('files', 10, '/a');
+      assert.quotaIsInherited('bytes', '1 KiB', '/a');
+      assert.dirQuotas(2048, 20);
+    });
+
+    describe('update modal', () => {
+      describe('max_files', () => {
+        beforeEach(() => {
+          mockLib.updateQuotaThroughModal('max_files', 5);
+        });
+
+        it('should update max_files correctly', () => {
+          expect(cephfsService.updateQuota).toHaveBeenCalledWith(1, '/a/c/b', { max_files: 5 });
+          assert.quotaIsNotInherited('files', 5, 10);
+        });
+
+        it('uses the correct form field', () => {
+          assert.quotaUpdateModalField('number', 'Max files', 'max_files', 20, 10, {
+            min: 'Value has to be at least 0 or more',
+            max: 'Value has to be at most 10 or less'
+          });
+        });
+
+        it('shows the right texts', () => {
+          assert.quotaUpdateModalTexts(
+            "Update CephFS files quota for '/a/c/b'",
+            "The inherited files quota 10 from '/a' is the maximum value to be used.",
+            "Updated CephFS files quota for '/a/c/b'"
+          );
+        });
+      });
+
+      describe('max_bytes', () => {
+        beforeEach(() => {
+          mockLib.updateQuotaThroughModal('max_bytes', 512);
+        });
+
+        it('should update max_files correctly', () => {
+          expect(cephfsService.updateQuota).toHaveBeenCalledWith(1, '/a/c/b', { max_bytes: 512 });
+          assert.quotaIsNotInherited('bytes', '512 B', 1024);
+        });
+
+        it('uses the correct form field', () => {
+          mockLib.updateQuotaThroughModal('max_bytes', 512);
+          assert.quotaUpdateModalField('binary', 'Max size', 'max_bytes', 2048, 1024);
+        });
+
+        it('shows the right texts', () => {
+          assert.quotaUpdateModalTexts(
+            "Update CephFS size quota for '/a/c/b'",
+            "The inherited size quota 1 KiB from '/a' is the maximum value to be used.",
+            "Updated CephFS size quota for '/a/c/b'"
+          );
+        });
+      });
+
+      describe('action behaviour', () => {
+        it('opens with next maximum as maximum if directory holds the current maximum', () => {
+          mockLib.updateQuotaThroughModal('max_bytes', 512);
+          mockLib.updateQuotaThroughModal('max_bytes', 888);
+          assert.quotaUpdateModalField('binary', 'Max size', 'max_bytes', 512, 1024);
+        });
+
+        it("uses 'Set' action instead of 'Update' if the quota is not set (0)", () => {
+          mockLib.updateQuotaThroughModal('max_bytes', 0);
+          mockLib.updateQuotaThroughModal('max_bytes', 200);
+          assert.quotaUpdateModalTexts(
+            "Set CephFS size quota for '/a/c/b'",
+            "The inherited size quota 1 KiB from '/a' is the maximum value to be used.",
+            "Set CephFS size quota for '/a/c/b'"
+          );
+        });
+      });
+    });
+
+    describe('unset modal', () => {
+      describe('max_files', () => {
+        beforeEach(() => {
+          mockLib.updateQuotaThroughModal('max_files', 5); // Sets usable quota
+          mockLib.unsetQuotaThroughModal('max_files');
+        });
+
+        it('should unset max_files correctly', () => {
+          expect(cephfsService.updateQuota).toHaveBeenCalledWith(1, '/a/c/b', { max_files: 0 });
+          assert.dirQuotas(2048, 0);
+        });
+
+        it('shows the right texts', () => {
+          assert.quotaUnsetModalTexts(
+            "Unset CephFS files quota for '/a/c/b'",
+            "Unset files quota 5 from '/a/c/b' in order to inherit files quota 10 from '/a'.",
+            "Unset CephFS files quota for '/a/c/b'"
+          );
+        });
+      });
+
+      describe('max_bytes', () => {
+        beforeEach(() => {
+          mockLib.updateQuotaThroughModal('max_bytes', 512); // Sets usable quota
+          mockLib.unsetQuotaThroughModal('max_bytes');
+        });
+
+        it('should unset max_files correctly', () => {
+          expect(cephfsService.updateQuota).toHaveBeenCalledWith(1, '/a/c/b', { max_bytes: 0 });
+          assert.dirQuotas(0, 20);
+        });
+
+        it('shows the right texts', () => {
+          assert.quotaUnsetModalTexts(
+            "Unset CephFS size quota for '/a/c/b'",
+            "Unset size quota 512 B from '/a/c/b' in order to inherit size quota 1 KiB from '/a'.",
+            "Unset CephFS size quota for '/a/c/b'"
+          );
+        });
+      });
+
+      describe('action behaviour', () => {
+        it('uses different Text if no quota is inherited', () => {
+          mockLib.selectNode('/a');
+          mockLib.unsetQuotaThroughModal('max_bytes');
+          assert.quotaUnsetModalTexts(
+            "Unset CephFS size quota for '/a'",
+            "Unset size quota 1 KiB from '/a' in order to have no quota on the directory.",
+            "Unset CephFS size quota for '/a'"
+          );
+        });
+
+        it('uses different Text if quota is already inherited', () => {
+          mockLib.unsetQuotaThroughModal('max_bytes');
+          assert.quotaUnsetModalTexts(
+            "Unset CephFS size quota for '/a/c/b'",
+            "Unset size quota 2 KiB from '/a/c/b' which isn't used because of the inheritance " +
+              "of size quota 1 KiB from '/a'.",
+            "Unset CephFS size quota for '/a/c/b'"
+          );
+        });
+      });
+    });
+  });
+
+  describe('table actions', () => {
+    let actions: CdTableAction[];
+
+    const empty = (): CdTableSelection => {
+      const selection = new CdTableSelection();
+      return selection;
+    };
+
+    const select = (value: number): CdTableSelection => {
+      const selection = new CdTableSelection();
+      selection.selected = [{ dirValue: value }];
+      return selection;
+    };
+
+    beforeEach(() => {
+      actions = component.quota.tableActions;
+    });
+
+    it("shows 'Set' for empty and not set quotas", () => {
+      const isSetVisible = actions[0].visible;
+      expect(isSetVisible(empty())).toBe(true);
+      expect(isSetVisible(select(0))).toBe(true);
+      expect(isSetVisible(select(1))).toBe(false);
+    });
+
+    it("shows 'Update' for set quotas only", () => {
+      const isUpdateVisible = actions[1].visible;
+      expect(isUpdateVisible(empty())).toBeFalsy();
+      expect(isUpdateVisible(select(0))).toBe(false);
+      expect(isUpdateVisible(select(1))).toBe(true);
+    });
+
+    it("only enables 'Unset' for set quotas only", () => {
+      const isUnsetDisabled = actions[2].disable;
+      expect(isUnsetDisabled(empty())).toBe(true);
+      expect(isUnsetDisabled(select(0))).toBe(true);
+      expect(isUnsetDisabled(select(1))).toBe(false);
+    });
+
+    it('should test all quota table actions permission combinations', () => {
+      const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+      const tableActions = permissionHelper.setPermissionsAndGetActions(
+        component.quota.tableActions
+      );
+
+      expect(tableActions).toEqual({
+        'create,update,delete': {
+          actions: ['Set', 'Update', 'Unset'],
+          primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' }
+        },
+        'create,update': {
+          actions: ['Set', 'Update', 'Unset'],
+          primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' }
+        },
+        'create,delete': {
+          actions: [],
+          primary: { multiple: '', executing: '', single: '', no: '' }
+        },
+        create: {
+          actions: [],
+          primary: { multiple: '', executing: '', single: '', no: '' }
+        },
+        'update,delete': {
+          actions: ['Set', 'Update', 'Unset'],
+          primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' }
+        },
+        update: {
+          actions: ['Set', 'Update', 'Unset'],
+          primary: { multiple: 'Set', executing: 'Set', single: 'Set', no: 'Set' }
+        },
+        delete: {
+          actions: [],
+          primary: { multiple: '', executing: '', single: '', no: '' }
+        },
+        'no-permissions': {
+          actions: [],
+          primary: { multiple: '', executing: '', single: '', no: '' }
+        }
+      });
+    });
+  });
 });
index 58f370de0b90fe4b928e480f401974e3b9a59ed8..ca93b8e67f3f31c9434113ccc28b8449f1d06b6a 100644 (file)
@@ -1,26 +1,50 @@
 import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Validators } from '@angular/forms';
 
 import { I18n } from '@ngx-translate/i18n-polyfill';
 import * as _ from 'lodash';
 import * as moment from 'moment';
 import { NodeEvent, Tree, TreeComponent, TreeModel } from 'ng2-tree';
-
 import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
+
 import { CephfsService } from '../../../shared/api/cephfs.service';
+import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component';
+import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
 import { Icons } from '../../../shared/enum/icons.enum';
 import { NotificationType } from '../../../shared/enum/notification-type.enum';
+import { CdValidators } from '../../../shared/forms/cd-validators';
+import { CdFormModalFieldConfig } from '../../../shared/models/cd-form-modal-field-config';
 import { CdTableAction } from '../../../shared/models/cd-table-action';
 import { CdTableColumn } from '../../../shared/models/cd-table-column';
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
-import { CephfsDir, CephfsSnapshot } from '../../../shared/models/cephfs-directory-models';
+import {
+  CephfsDir,
+  CephfsQuotas,
+  CephfsSnapshot
+} from '../../../shared/models/cephfs-directory-models';
 import { Permission } from '../../../shared/models/permissions';
 import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { NotificationService } from '../../../shared/services/notification.service';
 
+class QuotaSetting {
+  row: {
+    // Shows quota that is used for current directory
+    name: string;
+    value: number | string;
+    originPath: string;
+  };
+  quotaKey: string;
+  dirValue: number;
+  nextTreeMaximum: {
+    value: number;
+    path: string;
+  };
+}
+
 @Component({
   selector: 'cd-cephfs-directories',
   templateUrl: './cephfs-directories.component.html',
@@ -43,12 +67,13 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
 
   permission: Permission;
   selectedDir: CephfsDir;
-  settings: {
-    name: string;
-    value: number | string;
-    origin: string;
-  }[];
-  settingsColumns: CdTableColumn[];
+  settings: QuotaSetting[];
+  quota: {
+    columns: CdTableColumn[];
+    selection: CdTableSelection;
+    tableActions: CdTableAction[];
+    updateSelection: Function;
+  };
   snapshot: {
     columns: CdTableColumn[];
     selection: CdTableSelection;
@@ -63,32 +88,64 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
     private cephfsService: CephfsService,
     private cdDatePipe: CdDatePipe,
     private i18n: I18n,
+    private actionLabels: ActionLabelsI18n,
     private notificationService: NotificationService,
     private dimlessBinaryPipe: DimlessBinaryPipe
   ) {}
 
   ngOnInit() {
     this.permission = this.authStorageService.getPermissions().cephfs;
-    this.settingsColumns = [
-      {
-        prop: 'name',
-        name: this.i18n('Name'),
-        flexGrow: 1
-      },
-      {
-        prop: 'value',
-        name: this.i18n('Value'),
-        sortable: false,
-        flexGrow: 1
+    this.quota = {
+      columns: [
+        {
+          prop: 'row.name',
+          name: this.i18n('Name'),
+          flexGrow: 1
+        },
+        {
+          prop: 'row.value',
+          name: this.i18n('Value'),
+          sortable: false,
+          flexGrow: 1
+        },
+        {
+          prop: 'row.originPath',
+          name: this.i18n('Origin'),
+          sortable: false,
+          cellTemplate: this.originTmpl,
+          flexGrow: 1
+        }
+      ],
+      selection: new CdTableSelection(),
+      updateSelection: (selection: CdTableSelection) => {
+        this.quota.selection = selection;
       },
-      {
-        prop: 'origin',
-        name: this.i18n('Origin'),
-        sortable: false,
-        cellTemplate: this.originTmpl,
-        flexGrow: 1
-      }
-    ];
+      tableActions: [
+        {
+          name: this.actionLabels.SET,
+          icon: Icons.edit,
+          permission: 'update',
+          visible: (selection) =>
+            !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
+          click: () => this.updateQuotaModal()
+        },
+        {
+          name: this.actionLabels.UPDATE,
+          icon: Icons.edit,
+          permission: 'update',
+          visible: (selection) => selection.first() && selection.first().dirValue > 0,
+          click: () => this.updateQuotaModal()
+        },
+        {
+          name: this.actionLabels.UNSET,
+          icon: Icons.destroy,
+          permission: 'update',
+          disable: (selection) =>
+            !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
+          click: () => this.unsetQuotaModal()
+        }
+      ]
+    };
     this.snapshot = {
       columns: [
         {
@@ -115,14 +172,14 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
       },
       tableActions: [
         {
-          name: this.i18n('Create'),
+          name: this.actionLabels.CREATE,
           icon: Icons.add,
           permission: 'create',
           canBePrimary: (selection) => !selection.hasSelection,
           click: () => this.createSnapshot()
         },
         {
-          name: this.i18n('Delete'),
+          name: this.actionLabels.DELETE,
           icon: Icons.destroy,
           permission: 'delete',
           click: () => this.deleteSnapshotModal(),
@@ -227,29 +284,53 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
   }
 
   private setSettings(node: Tree) {
-    const files = this.getQuota(node, 'max_files');
-    const size = this.getQuota(node, 'max_bytes');
+    const readable = (value: number, fn?: (number) => number | string): number | string =>
+      value ? (fn ? fn(value) : value) : '';
+
     this.settings = [
-      {
-        name: 'Max files',
-        value: files.value,
-        origin: files.origin
-      },
-      {
-        name: 'Max size',
-        value: size.value !== '' ? this.dimlessBinaryPipe.transform(size.value) : '',
-        origin: size.origin
-      }
+      this.getQuota(node, 'max_files', readable),
+      this.getQuota(node, 'max_bytes', (value) =>
+        readable(value, (v) => this.dimlessBinaryPipe.transform(v))
+      )
     ];
   }
 
-  private getQuota(tree: Tree, quotaSetting: string): { value: string; origin: string } {
-    tree = this.getOrigin(tree, quotaSetting);
+  private getQuota(
+    tree: Tree,
+    quotaKey: string,
+    valueConvertFn: (number) => number | string
+  ): QuotaSetting {
+    // Get current maximum
+    const currentPath = tree.id;
+    tree = this.getOrigin(tree, quotaKey);
     const dir = this.getDirectory(tree);
-    const value = dir.quotas[quotaSetting];
+    const value = dir.quotas[quotaKey];
+    // Get next tree maximum
+    // => The value that isn't changeable through a change of the current directories quota value
+    let nextMaxValue = value;
+    let nextMaxPath = dir.path;
+    if (tree.id === currentPath) {
+      if (tree.parent.value === '/') {
+        // The value will never inherit any other value, so it has no maximum.
+        nextMaxValue = 0;
+      } else {
+        const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
+        nextMaxValue = nextMaxDir.quotas[quotaKey];
+        nextMaxPath = nextMaxDir.path;
+      }
+    }
     return {
-      value: value ? value : '',
-      origin: value ? dir.path : ''
+      row: {
+        name: quotaKey === 'max_bytes' ? this.i18n('Max size') : this.i18n('Max files'),
+        value: valueConvertFn(value),
+        originPath: value ? dir.path : ''
+      },
+      quotaKey,
+      dirValue: this.nodeIds[currentPath].quotas[quotaKey],
+      nextTreeMaximum: {
+        value: nextMaxValue,
+        path: nextMaxValue ? nextMaxPath : ''
+      }
     };
   }
 
@@ -274,6 +355,133 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
     return this.nodeIds[path];
   }
 
+  updateQuotaModal() {
+    const path = this.selectedDir.path;
+    const selection: QuotaSetting = this.quota.selection.first();
+    const nextMax = selection.nextTreeMaximum;
+    const key = selection.quotaKey;
+    const value = selection.dirValue;
+    this.modalService.show(FormModalComponent, {
+      initialState: {
+        titleText: this.getModalQuotaTitle(
+          value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
+          path
+        ),
+        message: nextMax.value
+          ? this.i18n('The inherited {{quotaValue}} is the maximum value to be used.', {
+              quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
+            })
+          : undefined,
+        fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
+        submitButtonText: 'Save',
+        onSubmit: (values) => this.updateQuota(values)
+      }
+    });
+  }
+
+  private getModalQuotaTitle(action: string, path: string): string {
+    return this.i18n("{{action}} CephFS {{quotaName}} quota for '{{path}}'", {
+      action,
+      quotaName: this.getQuotaName(),
+      path
+    });
+  }
+
+  private getQuotaName(): string {
+    return this.isBytesQuotaSelected() ? this.i18n('size') : this.i18n('files');
+  }
+
+  private isBytesQuotaSelected(): boolean {
+    return this.quota.selection.first().quotaKey === 'max_bytes';
+  }
+
+  private getQuotaValueFromPathMsg(value: number, path: string): string {
+    return this.i18n("{{quotaName}} quota {{value}} from '{{path}}'", {
+      value: this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value,
+      quotaName: this.getQuotaName(),
+      path
+    });
+  }
+
+  private getQuotaFormField(
+    label: string,
+    name: string,
+    value: number,
+    maxValue: number
+  ): CdFormModalFieldConfig {
+    const isBinary = name === 'max_bytes';
+    const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
+    if (maxValue) {
+      formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
+    }
+    const field: CdFormModalFieldConfig = {
+      type: isBinary ? 'binary' : 'number',
+      label,
+      name,
+      value,
+      validators: formValidators,
+      required: true
+    };
+    if (!isBinary) {
+      field.errors = {
+        min: this.i18n(`Value has to be at least {{value}} or more`, { value: 0 }),
+        max: this.i18n(`Value has to be at most {{value}} or less`, { value: maxValue })
+      };
+    }
+    return field;
+  }
+
+  private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
+    const path = this.selectedDir.path;
+    const key = this.quota.selection.first().quotaKey;
+    const action =
+      this.selectedDir.quotas[key] === 0
+        ? this.actionLabels.SET
+        : values[key] === 0
+        ? this.actionLabels.UNSET
+        : this.i18n('Updated');
+    this.cephfsService.updateQuota(this.id, path, values).subscribe(() => {
+      if (onSuccess) {
+        onSuccess();
+      }
+      this.notificationService.show(
+        NotificationType.success,
+        this.getModalQuotaTitle(action, path)
+      );
+      this.forceDirRefresh();
+    });
+  }
+
+  unsetQuotaModal() {
+    const path = this.selectedDir.path;
+    const selection: QuotaSetting = this.quota.selection.first();
+    const key = selection.quotaKey;
+    const nextMax = selection.nextTreeMaximum;
+    const dirValue = selection.dirValue;
+
+    this.modalRef = this.modalService.show(ConfirmationModalComponent, {
+      initialState: {
+        titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
+        buttonText: this.actionLabels.UNSET,
+        description: this.i18n(`{{action}} {{quotaValue}} {{conclusion}}.`, {
+          action: this.actionLabels.UNSET,
+          quotaValue: this.getQuotaValueFromPathMsg(dirValue, path),
+          conclusion:
+            nextMax.value > 0
+              ? nextMax.value > dirValue
+                ? this.i18n('in order to inherit {{quotaValue}}', {
+                    quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
+                  })
+                : this.i18n("which isn't used because of the inheritance of {{quotaValue}}", {
+                    quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
+                  })
+              : this.i18n('in order to have no quota on the directory')
+        }),
+        onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.hide())
+      }
+    });
+  }
+
   createSnapshot() {
     // Create a snapshot. Auto-generate a snapshot name by default.
     const path = this.selectedDir.path;
@@ -283,7 +491,7 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
         message: this.i18n('Please enter the name of the snapshot.'),
         fields: [
           {
-            type: 'inputText',
+            type: 'text',
             name: 'name',
             value: `${moment().toISOString(true)}`,
             required: true
@@ -314,17 +522,19 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
    */
   private forceDirRefresh() {
     const path = this.selectedNode.parent.id as string;
-    this.cephfsService.lsDir(this.id, path).subscribe((data) =>
+    this.cephfsService.lsDir(this.id, path).subscribe((data) => {
       data.forEach((d) => {
         Object.assign(this.dirs.find((sub) => sub.path === d.path), d);
-      })
-    );
+      });
+      // Now update quotas
+      this.setSettings(this.selectedNode);
+    });
   }
 
   deleteSnapshotModal() {
     this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
       initialState: {
-        itemDescription: 'CephFs Snapshot',
+        itemDescription: this.i18n('CephFs Snapshot'),
         itemNames: this.snapshot.selection.selected.map(
           (snapshot: CephfsSnapshot) => snapshot.name
         ),
index f348584ca29265055b670f62369f478119d822f3..8c10ed127eaa5a9cfaea2035c10651ca185c5805 100644 (file)
@@ -78,4 +78,21 @@ describe('CephfsService', () => {
     const req = httpTesting.expectOne('api/cephfs/1/rm_snapshot?path=%252Fsome%252Fpath&name=snap');
     expect(req.request.method).toBe('POST');
   });
+
+  it('should call updateQuota', () => {
+    service.updateQuota(1, '/some/path', { max_bytes: 1024 }).subscribe();
+    let req = httpTesting.expectOne('api/cephfs/1/set_quotas?path=%252Fsome%252Fpath');
+    expect(req.request.method).toBe('POST');
+    expect(req.request.body).toEqual({ max_bytes: 1024 });
+
+    service.updateQuota(1, '/some/path', { max_files: 10 }).subscribe();
+    req = httpTesting.expectOne('api/cephfs/1/set_quotas?path=%252Fsome%252Fpath');
+    expect(req.request.method).toBe('POST');
+    expect(req.request.body).toEqual({ max_files: 10 });
+
+    service.updateQuota(1, '/some/path', { max_bytes: 1024, max_files: 10 }).subscribe();
+    req = httpTesting.expectOne('api/cephfs/1/set_quotas?path=%252Fsome%252Fpath');
+    expect(req.request.method).toBe('POST');
+    expect(req.request.body).toEqual({ max_bytes: 1024, max_files: 10 });
+  });
 });
index 1c1da64ae77bae242c9ec9efafb2ab9083b26836..b851610103ed14687864091a8f51c538e977ecfc 100644 (file)
@@ -5,7 +5,7 @@ import * as _ from 'lodash';
 import { Observable } from 'rxjs';
 
 import { cdEncode } from '../decorators/cd-encode';
-import { CephfsDir } from '../models/cephfs-directory-models';
+import { CephfsDir, CephfsQuotas } from '../models/cephfs-directory-models';
 import { ApiModule } from './api.module';
 
 @cdEncode
@@ -55,13 +55,22 @@ export class CephfsService {
     if (!_.isUndefined(name)) {
       params = params.append('name', name);
     }
-    return this.http.post(`${this.baseURL}/${id}/mk_snapshot`, null, { params: params });
+    return this.http.post(`${this.baseURL}/${id}/mk_snapshot`, null, { params });
   }
 
   rmSnapshot(id, path, name) {
     let params = new HttpParams();
     params = params.append('path', path);
     params = params.append('name', name);
-    return this.http.post(`${this.baseURL}/${id}/rm_snapshot`, null, { params: params });
+    return this.http.post(`${this.baseURL}/${id}/rm_snapshot`, null, { params });
+  }
+
+  updateQuota(id, path, quotas: CephfsQuotas) {
+    let params = new HttpParams();
+    params = params.append('path', path);
+    return this.http.post(`${this.baseURL}/${id}/set_quotas`, quotas, {
+      observe: 'response',
+      params
+    });
   }
 }
index 53f2c124108dcd045d3121c4afe871fed0963838..cb99bd6338e9694832ffa85ef6961f97d4b915c9 100644 (file)
@@ -82,11 +82,13 @@ export class ActionLabelsI18n {
   REMOVE: string;
   EDIT: string;
   CANCEL: string;
+  CHANGE: string;
   COPY: string;
   CLONE: string;
   DEEP_SCRUB: string;
   DESTROY: string;
   EVICT: string;
+  EXPIRE: string;
   FLATTEN: string;
   MARK_DOWN: string;
   MARK_IN: string;
@@ -94,17 +96,18 @@ export class ActionLabelsI18n {
   MARK_OUT: string;
   PROTECT: string;
   PURGE: string;
+  RECREATE: string;
   RENAME: string;
   RESTORE: string;
   REWEIGHT: string;
   ROLLBACK: string;
   SCRUB: string;
+  SET: string;
   SHOW: string;
   TRASH: string;
   UNPROTECT: string;
-  RECREATE: string;
-  EXPIRE: string;
-  CHANGE: string;
+  UNSET: string;
+  UPDATE: string;
 
   constructor(private i18n: I18n) {
     /* Create a new item */
@@ -115,12 +118,15 @@ export class ActionLabelsI18n {
 
     /* Add an existing item to a container */
     this.ADD = this.i18n('Add');
+    this.SET = this.i18n('Set');
 
     /* Remove an item from a container WITHOUT deleting it */
     this.REMOVE = this.i18n('Remove');
+    this.UNSET = this.i18n('Unset');
 
     /* Make changes to an existing item */
     this.EDIT = this.i18n('Edit');
+    this.UPDATE = this.i18n('Update');
     this.CANCEL = this.i18n('Cancel');
 
     /* Non-standard actions */
index 3a95e7ddfe0cf095f292c18cf7d3420ec8d63d07..9cbe33d44748c8a2302e1ce3193436a9e8dc4663 100644 (file)
@@ -3,6 +3,12 @@
                 i18n>Failed to load data.</cd-alert-panel>
 
 <div class="dataTables_wrapper">
+  <div *ngIf="onlyActionHeader"
+       class="dataTables_header clearfix">
+    <div class="cd-datatable-actions">
+      <ng-content select=".only-table-actions"></ng-content>
+    </div>
+  </div>
   <div class="dataTables_header clearfix"
        *ngIf="toolHeader">
     <!-- actions -->
index 36d32cf81fdaf8caa15c4aaaaa36ea380d319432..0cb500440b57aae310a58dca5ac882d6070ce404 100644 (file)
@@ -68,6 +68,9 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
   // Method used for setting column widths.
   @Input()
   columnMode? = 'flex';
+  // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions"
+  @Input()
+  onlyActionHeader? = false;
   // Display the tool header, including reload button, pagination and search fields?
   @Input()
   toolHeader? = true;
index f584d1f4bdf1830063affc77b7242aa6bd361615..92186aecc9617a50ee579e2f856d3a4a360fe09c 100644 (file)
@@ -7,8 +7,8 @@ export class CephfsSnapshot {
 }
 
 export class CephfsQuotas {
-  max_bytes: number;
-  max_files: number;
+  max_bytes?: number;
+  max_files?: number;
 }
 
 export class CephfsDir {
index 926ab32cac454dc20a52dd8e33f2aa64977c0701..75ac8ba3dc732adb3af1510aefcd14e4120eac8c 100644 (file)
@@ -224,7 +224,9 @@ class CephFS(object):
         :param max_files: The file limit.
         :type max_files: int | None
         """
-        self.cfs.setxattr(path, 'ceph.quota.max_bytes',
-                          str(max_bytes if max_bytes else 0).encode(), 0)
-        self.cfs.setxattr(path, 'ceph.quota.max_files',
-                          str(max_files if max_files else 0).encode(), 0)
+        if max_bytes is not None:
+            self.cfs.setxattr(path, 'ceph.quota.max_bytes',
+                              str(max_bytes).encode(), 0)
+        if max_files is not None:
+            self.cfs.setxattr(path, 'ceph.quota.max_files',
+                              str(max_files).encode(), 0)