]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: CephFS directory component
authorStephan Müller <smueller@suse.com>
Wed, 21 Aug 2019 08:34:02 +0000 (10:34 +0200)
committerStephan Müller <smueller@suse.com>
Mon, 28 Oct 2019 10:49:15 +0000 (11:49 +0100)
Now it's possible to list directories of a CephFS in a tree view inside
the dashboard.

Currently the tree is not refreshed every 5 seconds as the listing of
directories is a heavier operation.

When a directory is clicked, the dashboard will fetch it's
subdirectories and the subdirectories of it's subdirectories in order to
show if the subdirectories have subdirectories.

The details of a directory show the full path, it's quota values and
the path they originate from and a list of all snapshots.

Fixes: https://tracker.ceph.com/issues/41575
Signed-off-by: Stephan Müller <smueller@suse.com>
13 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 [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts [new file with mode: 0644]
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-tabs/cephfs-tabs.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.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/models/cephfs-directory-models.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/services/cephfs.py

index 8eba3327e4de4197214d52a8b7ce447af423c264..bd8666ea49906be4ce4693a9a54c3a981a5a4adc 100644 (file)
@@ -15,9 +15,34 @@ class CephfsTest(DashboardTestCase):
         self.assertIn(key, data)
         self.assertIsNotNone(data[key])
 
+    def get_fs_id(self):
+        return self.fs.get_namespace_id()
+
+    def mk_dirs(self, path, expectedStatus=200):
+        self._post("/api/cephfs/{}/mk_dirs".format(self.get_fs_id()),
+                   params={'path': path})
+        self.assertStatus(expectedStatus)
+
+    def rm_dir(self, path, expectedStatus=200):
+        self._post("/api/cephfs/{}/rm_dir".format(self.get_fs_id()),
+                   params={'path': path})
+        self.assertStatus(expectedStatus)
+
+    def ls_dir(self, path, expectedLength, depth = None):
+        params = {'path': path}
+        if depth is not None:
+            params['depth'] = depth
+        data = self._get("/api/cephfs/{}/ls_dir".format(self.get_fs_id()),
+                         params=params)
+        self.assertStatus(200)
+        self.assertIsInstance(data, list)
+        self.assertEqual(len(data), expectedLength)
+        return data
+
+
     @DashboardTestCase.RunAs('test', 'test', ['block-manager'])
     def test_access_permissions(self):
-        fs_id = self.fs.get_namespace_id()
+        fs_id = self.get_fs_id()
         self._get("/api/cephfs/{}/clients".format(fs_id))
         self.assertStatus(403)
         self._get("/api/cephfs/{}".format(fs_id))
@@ -28,7 +53,7 @@ class CephfsTest(DashboardTestCase):
         self.assertStatus(403)
 
     def test_cephfs_clients(self):
-        fs_id = self.fs.get_namespace_id()
+        fs_id = self.get_fs_id()
         data = self._get("/api/cephfs/{}/clients".format(fs_id))
         self.assertStatus(200)
 
@@ -36,12 +61,12 @@ class CephfsTest(DashboardTestCase):
         self.assertIn('data', data)
 
     def test_cephfs_evict_client_does_not_exist(self):
-        fs_id = self.fs.get_namespace_id()
+        fs_id = self.get_fs_id()
         data = self._delete("/api/cephfs/{}/client/1234".format(fs_id))
         self.assertStatus(404)
 
     def test_cephfs_get(self):
-        fs_id = self.fs.get_namespace_id()
+        fs_id = self.get_fs_id()
         data = self._get("/api/cephfs/{}/".format(fs_id))
         self.assertStatus(200)
 
@@ -50,7 +75,7 @@ class CephfsTest(DashboardTestCase):
         self.assertToHave(data, 'versions')
 
     def test_cephfs_mds_counters(self):
-        fs_id = self.fs.get_namespace_id()
+        fs_id = self.get_fs_id()
         data = self._get("/api/cephfs/{}/mds_counters".format(fs_id))
         self.assertStatus(200)
 
@@ -76,7 +101,7 @@ class CephfsTest(DashboardTestCase):
         self.assertToHave(cephfs, 'mdsmap')
 
     def test_cephfs_tabs(self):
-        fs_id = self.fs.get_namespace_id()
+        fs_id = self.get_fs_id()
         data = self._get("/ui-api/cephfs/{}/tabs".format(fs_id))
         self.assertStatus(200)
         self.assertIsInstance(data, dict)
@@ -116,57 +141,31 @@ class CephfsTest(DashboardTestCase):
         self.assertIsInstance(clients['status'], int)
 
     def test_ls_mk_rm_dir(self):
-        fs_id = self.fs.get_namespace_id()
-        data = self._get("/api/cephfs/{}/ls_dir".format(fs_id),
-                         params={'path': '/'})
-        self.assertStatus(200)
-        self.assertIsInstance(data, list)
-        self.assertEqual(len(data), 0)
+        self.ls_dir('/', 0)
 
-        self._post("/api/cephfs/{}/mk_dirs".format(fs_id),
-                   params={'path': '/pictures/birds'})
-        self.assertStatus(200)
+        self.mk_dirs('/pictures/birds')
+        self.ls_dir('/', 2, 3)
+        self.ls_dir('/pictures', 1)
 
-        data = self._get("/api/cephfs/{}/ls_dir".format(fs_id),
-                         params={'path': '/pictures'})
-        self.assertStatus(200)
-        self.assertIsInstance(data, list)
-        self.assertEqual(len(data), 1)
-
-        self._post("/api/cephfs/{}/rm_dir".format(fs_id),
-                   params={'path': '/pictures'})
-        self.assertStatus(500)
-        self._post("/api/cephfs/{}/rm_dir".format(fs_id),
-                   params={'path': '/pictures/birds'})
-        self.assertStatus(200)
-        self._post("/api/cephfs/{}/rm_dir".format(fs_id),
-                   params={'path': '/pictures'})
-        self.assertStatus(200)
+        self.rm_dir('/pictures', 500)
+        self.rm_dir('/pictures/birds')
+        self.rm_dir('/pictures')
 
-        data = self._get("/api/cephfs/{}/ls_dir".format(fs_id),
-                         params={'path': '/'})
-        self.assertStatus(200)
-        self.assertIsInstance(data, list)
-        self.assertEqual(len(data), 0)
+        self.ls_dir('/', 0)
 
     def test_snapshots(self):
-        fs_id = self.fs.get_namespace_id()
-        self._post("/api/cephfs/{}/mk_dirs".format(fs_id),
-                   params={'path': '/movies/dune'})
-        self.assertStatus(200)
+        fs_id = self.get_fs_id()
+        self.mk_dirs('/movies/dune/extended_version')
 
         self._post("/api/cephfs/{}/mk_snapshot".format(fs_id),
                    params={'path': '/movies/dune', 'name': 'test'})
         self.assertStatus(200)
 
-        data = self._get("/api/cephfs/{}/ls_dir".format(fs_id),
-                         params={'path': '/movies'})
-        self.assertStatus(200)
-        self.assertIsInstance(data, list)
-        self.assertEqual(len(data), 1)
+        data = self.ls_dir('/movies', 1)
         self.assertSchema(data[0], JObj(sub_elems={
             'name': JLeaf(str),
             'path': JLeaf(str),
+            'parent': JLeaf(str),
             'snapshots': JList(JObj(sub_elems={
                 'name': JLeaf(str),
                 'path': JLeaf(str),
@@ -183,20 +182,20 @@ class CephfsTest(DashboardTestCase):
         self.assertEqual(snapshot['name'], "test")
         self.assertEqual(snapshot['path'], "/movies/dune/.snap/test")
 
+        # Should have filtered out "_test_$timestamp"
+        data = self.ls_dir('/movies/dune', 1)
+        snapshots = data[0]['snapshots']
+        self.assertEqual(len(snapshots), 0)
+
         self._post("/api/cephfs/{}/rm_snapshot".format(fs_id),
                    params={'path': '/movies/dune', 'name': 'test'})
         self.assertStatus(200)
 
-        data = self._get("/api/cephfs/{}/ls_dir".format(fs_id),
-                         params={'path': '/movies'})
-        self.assertStatus(200)
+        data = self.ls_dir('/movies', 1)
         self.assertEqual(len(data[0]['snapshots']), 0)
 
-        # Cleanup. Note, the CephFS Python extension (and therefor the Dashoard
+        # Cleanup. Note, the CephFS Python extension (and therefor the Dashboard
         # REST API) does not support recursive deletion of a directory.
-        self._post("/api/cephfs/{}/rm_dir".format(fs_id),
-                   params={'path': '/movies/dune'})
-        self.assertStatus(200)
-        self._post("/api/cephfs/{}/rm_dir".format(fs_id),
-                   params={'path': '/movies'})
-        self.assertStatus(200)
+        self.rm_dir('/movies/dune/extended_version')
+        self.rm_dir('/movies/dune')
+        self.rm_dir('/movies')
index 853bea2d148f3a4a257b4a3c440b1098bf49f3db..d0019b8922b925b7507129bdfffd712cb3ae20f9 100644 (file)
@@ -329,7 +329,7 @@ class CephFS(RESTController):
         return CephFS_(fs_name)
 
     @RESTController.Resource('GET')
-    def ls_dir(self, fs_id, path=None):
+    def ls_dir(self, fs_id, path=None, depth=1):
         """
         List directories of specified path.
         :param fs_id: The filesystem identifier.
@@ -344,12 +344,13 @@ class CephFS(RESTController):
             path = os.path.normpath(path)
         try:
             cfs = self._cephfs_instance(fs_id)
-            paths = cfs.ls_dir(path, 1)
+            paths = cfs.ls_dir(path, int(depth))
             # Convert (bytes => string), prettify paths (strip slashes)
             # and append additional information.
             paths = [{
                 'name': os.path.basename(p.decode()),
                 'path': p.decode(),
+                'parent': os.path.dirname(p.decode()),
                 'snapshots': cfs.ls_snapshots(p.decode()),
                 'quotas': cfs.get_quotas(p.decode())
             } for p in paths if p != path.encode()]
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html
new file mode 100644 (file)
index 0000000..a8acd26
--- /dev/null
@@ -0,0 +1,41 @@
+<div class="row">
+  <div class="col-sm-4">
+    <tree [tree]="tree"
+          (nodeSelected)="onNodeSelected($event)">
+      <ng-template let-node>
+        <span class="node-name"
+              [innerHTML]="node.value"></span>
+      </ng-template>
+    </tree>
+  </div>
+  <!-- Selection details -->
+  <div class="col-sm-8 metadata"
+       *ngIf="selectedDir">
+    <div class="card">
+      <div class="card-header">
+        {{ selectedDir.path }}
+      </div>
+      <div class="card-body">
+        <legend i18n>Quotas</legend>
+        <cd-table [data]="settings"
+                  [columns]="settingsColumns"
+                  [limit]="0"
+                  [footer]="false"
+                  [toolHeader]="false">
+        </cd-table>
+
+        <legend i18n>Snapshots</legend>
+        <cd-table [sorts]="snapshot.sortProperties"
+                  [data]="selectedDir.snapshots"
+                  [columns]="snapshot.columns">
+        </cd-table>
+      </div>
+    </div>
+  </div>
+</div>
+
+<ng-template #origin
+             let-row="row"
+             let-value="value">
+  <span class="quota-origin" (click)="selectOrigin(value)">{{value}}</span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.scss
new file mode 100644 (file)
index 0000000..b350a04
--- /dev/null
@@ -0,0 +1,9 @@
+@import 'ng2-tree.scss';
+
+.quota-origin {
+  &:hover {
+    color: #212121;
+  }
+  cursor: pointer;
+  color: #2b99a8;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts
new file mode 100644 (file)
index 0000000..90d283e
--- /dev/null
@@ -0,0 +1,323 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NodeEvent, Tree, TreeModel, TreeModule } from 'ng2-tree';
+import { of } from 'rxjs';
+
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { CephfsService } from '../../../shared/api/cephfs.service';
+import {
+  CephfsDir,
+  CephfsQuotas,
+  CephfsSnapshot
+} from '../../../shared/models/cephfs-directory-models';
+import { SharedModule } from '../../../shared/shared.module';
+import { CephfsDirectoriesComponent } from './cephfs-directories.component';
+
+describe('CephfsDirectoriesComponent', () => {
+  let component: CephfsDirectoriesComponent;
+  let fixture: ComponentFixture<CephfsDirectoriesComponent>;
+  let lsDirSpy;
+  let originalDate;
+
+  // Get's private attributes or functions
+  const get = {
+    nodeIds: (): { [path: string]: CephfsDir } => component['nodeIds'],
+    dirs: (): CephfsDir[] => component['dirs'],
+    requestedPaths: (): string[] => component['requestedPaths']
+  };
+
+  // Object contains mock data that will be reset before each test.
+  let mockData: {
+    nodes: TreeModel[];
+    parent: Tree;
+  };
+
+  // Object contains mock functions
+  const mockLib = {
+    quotas: (max_bytes: number, max_files: number): CephfsQuotas => ({ max_bytes, max_files }),
+    snapshots: (dirPath: string, howMany: number): CephfsSnapshot[] => {
+      const name = 'someSnapshot';
+      const snapshots = [];
+      for (let i = 0; i < howMany; i++) {
+        const path = `${dirPath}/.snap/${name}${i}`;
+        const created = new Date(
+          +new Date() - 3600 * 24 * 1000 * howMany * (howMany - i)
+        ).toString();
+        snapshots.push({ name, path, created });
+      }
+      return snapshots;
+    },
+    dir: (path: string, name: string, modifier: number): CephfsDir => {
+      const dirPath = `${path === '/' ? '' : path}/${name}`;
+      return {
+        name,
+        path: dirPath,
+        parent: path,
+        quotas: mockLib.quotas(1024 * modifier, 10 * modifier),
+        snapshots: mockLib.snapshots(path, modifier)
+      };
+    },
+    // Only used inside other mocks
+    lsSingleDir: (path = ''): CephfsDir[] => {
+      if (path.includes('b')) {
+        // 'b' has no sub directories
+        return [];
+      }
+      return [
+        // Directories are not sorted!
+        mockLib.dir(path, 'c', 3),
+        mockLib.dir(path, 'a', 1),
+        mockLib.dir(path, 'b', 2)
+      ];
+    },
+    lsDir: (_id: number, path = '') => {
+      // will return 2 levels deep
+      let data = mockLib.lsSingleDir(path);
+      const paths = data.map((dir) => dir.path);
+      paths.forEach((pathL2) => {
+        data = data.concat(mockLib.lsSingleDir(pathL2));
+      });
+      return of(data);
+    },
+    date: (arg) => (arg ? new originalDate(arg) : new Date('2022-02-22T00:00:00')),
+    getControllerByPath: (path: string) => {
+      return {
+        expand: () => mockLib.expand(path),
+        select: () => component.onNodeSelected(mockLib.getNodeEvent(path))
+      };
+    },
+    // Only used inside other mocks to mock "tree.expand" of every node
+    expand: (path: string) => {
+      component.updateDirectory(path, (nodes) => (mockData.nodes = mockData.nodes.concat(nodes)));
+    },
+    getNodeEvent: (path: string): NodeEvent => {
+      const tree = mockData.nodes.find((n) => n.id === path) as Tree;
+      if (mockData.parent) {
+        tree.parent = mockData.parent;
+      } else {
+        const dir = get.nodeIds()[path];
+        const parentNode = mockData.nodes.find((n) => n.id === dir.parent);
+        tree.parent = parentNode as Tree;
+      }
+      return { node: tree } as NodeEvent;
+    },
+    changeId: (id: number) => {
+      component.id = id;
+      component.ngOnChanges();
+      mockData.nodes = [component.tree].concat(component.tree.children);
+    },
+    selectNode: (path: string) => {
+      mockLib.getControllerByPath(path).select();
+    },
+    mkDir: (path: string, name: string, maxFiles: number, maxBytes: number) => {
+      const dir = mockLib.dir(path, name, 3);
+      dir.quotas.max_bytes = maxBytes * 1024;
+      dir.quotas.max_files = maxFiles;
+      get.nodeIds()[dir.path] = dir;
+      mockData.nodes.push({
+        id: dir.path,
+        value: name
+      });
+    }
+  };
+
+  // Expects that are used frequently
+  const assert = {
+    dirLength: (n: number) => expect(get.dirs().length).toBe(n),
+    nodeLength: (n: number) => expect(mockData.nodes.length).toBe(n),
+    lsDirCalledTimes: (n: number) => expect(lsDirSpy).toHaveBeenCalledTimes(n),
+    requestedPaths: (expected: string[]) => expect(get.requestedPaths()).toEqual(expected),
+    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 }
+      ])
+  };
+
+  configureTestBed({
+    imports: [HttpClientTestingModule, SharedModule, TreeModule],
+    declarations: [CephfsDirectoriesComponent],
+    providers: [i18nProviders]
+  });
+
+  beforeEach(() => {
+    mockData = {
+      nodes: undefined,
+      parent: undefined
+    };
+    originalDate = Date;
+    spyOn(global, 'Date').and.callFake(mockLib.date);
+
+    lsDirSpy = spyOn(TestBed.get(CephfsService), 'lsDir').and.callFake(mockLib.lsDir);
+
+    fixture = TestBed.createComponent(CephfsDirectoriesComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+
+    spyOn(component.treeComponent, 'getControllerByNodeId').and.callFake((id) =>
+      mockLib.getControllerByPath(id)
+    );
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('calls lsDir only if an id exits', () => {
+    component.ngOnChanges();
+    assert.lsDirCalledTimes(0);
+
+    mockLib.changeId(1);
+    assert.lsDirCalledTimes(1);
+    expect(lsDirSpy).toHaveBeenCalledWith(1, '/');
+
+    mockLib.changeId(2);
+    assert.lsDirCalledTimes(2);
+    expect(lsDirSpy).toHaveBeenCalledWith(2, '/');
+  });
+
+  describe('listing sub directories', () => {
+    beforeEach(() => {
+      mockLib.changeId(1);
+      /**
+       * Tree looks like this:
+       * v /
+       *   > a
+       *   * b
+       *   > c
+       * */
+    });
+
+    it('expands first level', () => {
+      // Tree will only show '*' if nor 'loadChildren' or 'children' are defined
+      expect(
+        mockData.nodes.map((node) => ({ [node.id]: Boolean(node.loadChildren || node.children) }))
+      ).toEqual([{ '/': true }, { '/a': true }, { '/b': false }, { '/c': true }]);
+    });
+
+    it('resets all dynamic content on id change', () => {
+      mockLib.selectNode('/a');
+      /**
+       * Tree looks like this:
+       * v /
+       *   v a <- Selected
+       *     > a
+       *     * b
+       *     > c
+       *   * b
+       *   > c
+       * */
+      assert.requestedPaths(['/', '/a']);
+      assert.nodeLength(7);
+      assert.dirLength(15);
+      expect(component.selectedDir).toBeDefined();
+
+      mockLib.changeId(undefined);
+      assert.dirLength(0);
+      assert.requestedPaths([]);
+      expect(component.selectedDir).not.toBeDefined();
+    });
+
+    it('should select a node and show the directory contents', () => {
+      mockLib.selectNode('/a');
+      const dir = get.dirs().find((d) => d.path === '/a');
+      expect(component.selectedDir).toEqual(dir);
+      assert.quotaSettings(10, '/a', '1 KiB', '/a');
+    });
+
+    it('should extend the list by subdirectories when expanding and omit already called path', () => {
+      mockLib.selectNode('/a');
+      mockLib.selectNode('/a/c');
+      /**
+       * Tree looks like this:
+       * v /
+       *   v a
+       *     > a
+       *     * b
+       *     v c <- Selected
+       *       > a
+       *       * b
+       *       > c
+       *   * b
+       *   > c
+       * */
+      assert.lsDirCalledTimes(3);
+      assert.requestedPaths(['/', '/a', '/a/c']);
+      assert.dirLength(21);
+      assert.nodeLength(10);
+    });
+
+    it('should select parent by path', () => {
+      mockLib.selectNode('/a');
+      mockLib.selectNode('/a/c');
+      mockLib.selectNode('/a/c/a');
+      component.selectOrigin('/a');
+      expect(component.selectedDir.path).toBe('/a');
+    });
+
+    it('should omit call for directories that have no sub directories', () => {
+      mockLib.selectNode('/b');
+      /**
+       * Tree looks like this:
+       * v /
+       *   > a
+       *   * b <- Selected
+       *   > c
+       * */
+      assert.lsDirCalledTimes(1);
+      assert.requestedPaths(['/']);
+      assert.nodeLength(4);
+    });
+
+    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('', '', '', '');
+      });
+
+      it('should use quota from upper parents', () => {
+        setUpDirs([[100, 0], [0, 8], [0, 0], [0, 0]]);
+        assert.quotaSettings(100, '/1', '8 KiB', '/1/2');
+      });
+
+      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');
+      });
+
+      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');
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts
new file mode 100644 (file)
index 0000000..b70286f
--- /dev/null
@@ -0,0 +1,239 @@
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import { SortDirection, SortPropDir } from '@swimlane/ngx-datatable';
+import * as _ from 'lodash';
+import { NodeEvent, Tree, TreeComponent, TreeModel } from 'ng2-tree';
+
+import { CephfsService } from '../../../shared/api/cephfs.service';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CephfsDir } from '../../../shared/models/cephfs-directory-models';
+import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
+import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+
+@Component({
+  selector: 'cd-cephfs-directories',
+  templateUrl: './cephfs-directories.component.html',
+  styleUrls: ['./cephfs-directories.component.scss']
+})
+export class CephfsDirectoriesComponent implements OnInit, OnChanges {
+  @ViewChild(TreeComponent, { static: true })
+  treeComponent: TreeComponent;
+  @ViewChild('origin', { static: true })
+  originTmpl: TemplateRef<any>;
+
+  @Input()
+  id: number;
+
+  private dirs: CephfsDir[];
+  private nodeIds: { [path: string]: CephfsDir };
+  private requestedPaths: string[];
+
+  selectedDir: CephfsDir;
+  tree: TreeModel;
+  settings: {
+    name: string;
+    value: number | string;
+    origin: string;
+  }[];
+
+  settingsColumns: CdTableColumn[];
+  snapshot: { columns: CdTableColumn[]; sortProperties: SortPropDir[] };
+
+  constructor(
+    private cephfsService: CephfsService,
+    private cdDatePipe: CdDatePipe,
+    private i18n: I18n,
+    private dimlessBinaryPipe: DimlessBinaryPipe
+  ) {}
+
+  ngOnInit() {
+    this.settingsColumns = [
+      {
+        prop: 'name',
+        name: this.i18n('Name'),
+        flexGrow: 1
+      },
+      {
+        prop: 'value',
+        name: this.i18n('Value'),
+        sortable: false,
+        flexGrow: 1
+      },
+      {
+        prop: 'origin',
+        name: this.i18n('Origin'),
+        sortable: false,
+        cellTemplate: this.originTmpl,
+        flexGrow: 1
+      }
+    ];
+    this.snapshot = {
+      columns: [
+        {
+          prop: 'name',
+          name: this.i18n('Name'),
+          flexGrow: 1
+        },
+        {
+          prop: 'path',
+          name: this.i18n('Path'),
+          isHidden: true,
+          flexGrow: 2
+        },
+        {
+          prop: 'created',
+          name: this.i18n('Created'),
+          flexGrow: 1,
+          pipe: this.cdDatePipe
+        }
+      ],
+      sortProperties: [
+        {
+          dir: SortDirection.desc,
+          prop: 'created'
+        }
+      ]
+    };
+  }
+
+  ngOnChanges() {
+    this.selectedDir = undefined;
+    this.dirs = [];
+    this.requestedPaths = [];
+    this.nodeIds = {};
+    if (_.isUndefined(this.id)) {
+      this.setRootNode([]);
+    } else {
+      this.firstCall();
+    }
+  }
+
+  private setRootNode(nodes: TreeModel[]) {
+    const tree: TreeModel = {
+      value: '/',
+      id: '/',
+      settings: {
+        selectionAllowed: false,
+        static: true
+      }
+    };
+    if (nodes.length > 0) {
+      tree.children = nodes;
+    }
+    this.tree = tree;
+  }
+
+  private firstCall() {
+    this.updateDirectory('/', (nodes) => this.setRootNode(nodes));
+  }
+
+  updateDirectory(path: string, callback: (x: any[]) => void) {
+    if (
+      !this.requestedPaths.includes(path) &&
+      (path === '/' || this.getSubDirectories(path).length > 0)
+    ) {
+      this.requestedPaths.push(path);
+      this.cephfsService
+        .lsDir(this.id, path)
+        .subscribe((data) => this.loadDirectory(data, path, callback));
+    } else {
+      this.getChildren(path, callback);
+    }
+  }
+
+  private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
+    return tree.filter((d) => d.parent === path);
+  }
+
+  private loadDirectory(data: CephfsDir[], path: string, callback: (x: any[]) => void) {
+    if (path !== '/') {
+      // Removes duplicate directories
+      data = data.filter((dir) => dir.parent !== path);
+    }
+    const dirs = this.dirs.concat(data);
+    this.dirs = dirs;
+    this.getChildren(path, callback);
+  }
+
+  private getChildren(path: string, callback: (x: any[]) => void) {
+    const subTree = this.getSubTree(path);
+    const nodes = _.sortBy(this.getSubDirectories(path), 'path').map((d) => {
+      this.nodeIds[d.path] = d;
+      const newNode: TreeModel = {
+        value: d.name,
+        id: d.path,
+        settings: { static: true }
+      };
+      if (this.getSubDirectories(d.path, subTree).length > 0) {
+        // LoadChildren will be triggered if a node is expanded
+        newNode.loadChildren = (treeCallback) => this.updateDirectory(d.path, treeCallback);
+      }
+      return newNode;
+    });
+    callback(nodes);
+  }
+
+  private getSubTree(path: string): CephfsDir[] {
+    return this.dirs.filter((d) => d.parent.startsWith(path));
+  }
+
+  selectOrigin(path) {
+    this.treeComponent.getControllerByNodeId(path).select();
+  }
+
+  onNodeSelected(e: NodeEvent) {
+    const node = e.node;
+    this.treeComponent.getControllerByNodeId(node.id).expand();
+    this.setSettings(node);
+    this.selectedDir = this.getDirectory(node);
+  }
+
+  private setSettings(node: Tree) {
+    const files = this.getQuota(node, 'max_files');
+    const size = this.getQuota(node, 'max_bytes');
+    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
+      }
+    ];
+  }
+
+  private getQuota(tree: Tree, quotaSetting: string): { value: string; origin: string } {
+    tree = this.getOrigin(tree, quotaSetting);
+    const dir = this.getDirectory(tree);
+    const value = dir.quotas[quotaSetting];
+    return {
+      value: value ? value : '',
+      origin: value ? dir.path : ''
+    };
+  }
+
+  private getOrigin(tree: Tree, quotaSetting: string): Tree {
+    if (tree.parent.value !== '/') {
+      const current = this.getQuotaFromTree(tree, quotaSetting);
+      const originTree = this.getOrigin(tree.parent, quotaSetting);
+      const inherited = this.getQuotaFromTree(originTree, quotaSetting);
+
+      const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
+      return useOrigin ? originTree : tree;
+    }
+    return tree;
+  }
+
+  private getQuotaFromTree(tree: Tree, quotaSetting: string): number {
+    return this.getDirectory(tree).quotas[quotaSetting];
+  }
+
+  private getDirectory(node: Tree): CephfsDir {
+    const path = node.id as string;
+    return this.nodeIds[path];
+  }
+}
index abbc9bec734e734dcf6666a1fd06b9bbab709517..c2fc3e2eb2986def167f9e8216fa57a6a168e00d 100644 (file)
                        (triggerApiUpdate)="refresh()">
     </cd-cephfs-clients>
   </tab>
+  <tab i18n-heading
+       heading="Directories">
+    <cd-cephfs-directories [id]="id">
+    </cd-cephfs-directories>
+  </tab>
   <tab i18n-heading
        *ngIf="grafanaPermission.read && grafanaId"
        heading="Performance Details">
index e0b3258b90fa4f833882ec03706441b92e56e25c..64170da229addf17edb33ae76cbe1807b34b3fbf 100644 (file)
@@ -3,6 +3,7 @@ import { Component, Input } from '@angular/core';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 
 import * as _ from 'lodash';
+import { TreeModule } from 'ng2-tree';
 import { TabsModule } from 'ngx-bootstrap/tabs';
 import { ToastrModule } from 'ngx-toastr';
 import { of } from 'rxjs';
@@ -14,6 +15,7 @@ import { CdTableSelection } from '../../../shared/models/cd-table-selection';
 import { SharedModule } from '../../../shared/shared.module';
 import { CephfsClientsComponent } from '../cephfs-clients/cephfs-clients.component';
 import { CephfsDetailComponent } from '../cephfs-detail/cephfs-detail.component';
+import { CephfsDirectoriesComponent } from '../cephfs-directories/cephfs-directories.component';
 import { CephfsTabsComponent } from './cephfs-tabs.component';
 
 describe('CephfsTabsComponent', () => {
@@ -80,11 +82,18 @@ describe('CephfsTabsComponent', () => {
   }
 
   configureTestBed({
-    imports: [SharedModule, TabsModule.forRoot(), HttpClientTestingModule, ToastrModule.forRoot()],
+    imports: [
+      SharedModule,
+      TabsModule.forRoot(),
+      HttpClientTestingModule,
+      TreeModule,
+      ToastrModule.forRoot()
+    ],
     declarations: [
       CephfsTabsComponent,
       CephfsChartStubComponent,
       CephfsDetailComponent,
+      CephfsDirectoriesComponent,
       CephfsClientsComponent
     ],
     providers: [i18nProviders]
index 830c2155339a1cb37734e3c0f243d6f66c969497..fedb1154f7efb12ce37d7937e9aa951b04fafb7c 100644 (file)
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
 
 import { ChartsModule } from 'ng2-charts';
+import { TreeModule } from 'ng2-tree';
 import { ProgressbarModule } from 'ngx-bootstrap/progressbar';
 import { TabsModule } from 'ngx-bootstrap/tabs';
 
@@ -10,6 +11,7 @@ import { SharedModule } from '../../shared/shared.module';
 import { CephfsChartComponent } from './cephfs-chart/cephfs-chart.component';
 import { CephfsClientsComponent } from './cephfs-clients/cephfs-clients.component';
 import { CephfsDetailComponent } from './cephfs-detail/cephfs-detail.component';
+import { CephfsDirectoriesComponent } from './cephfs-directories/cephfs-directories.component';
 import { CephfsListComponent } from './cephfs-list/cephfs-list.component';
 import { CephfsTabsComponent } from './cephfs-tabs/cephfs-tabs.component';
 
@@ -19,6 +21,7 @@ import { CephfsTabsComponent } from './cephfs-tabs/cephfs-tabs.component';
     SharedModule,
     AppRoutingModule,
     ChartsModule,
+    TreeModule,
     ProgressbarModule.forRoot(),
     TabsModule.forRoot()
   ],
@@ -27,7 +30,8 @@ import { CephfsTabsComponent } from './cephfs-tabs/cephfs-tabs.component';
     CephfsClientsComponent,
     CephfsChartComponent,
     CephfsListComponent,
-    CephfsTabsComponent
+    CephfsTabsComponent,
+    CephfsDirectoriesComponent
   ]
 })
 export class CephfsModule {}
index 04be758665fdcbc22b15f426bbe44a1c75650bda..5910f5986422af0be9d2316c1584135d9937844a 100644 (file)
@@ -55,4 +55,12 @@ describe('CephfsService', () => {
     const req = httpTesting.expectOne('api/cephfs/1/mds_counters');
     expect(req.request.method).toBe('GET');
   });
+
+  it('should call lsDir', () => {
+    service.lsDir(1).subscribe();
+    const req = httpTesting.expectOne('api/cephfs/1/ls_dir?depth=2');
+    expect(req.request.method).toBe('GET');
+    service.lsDir(2, '/some/path').subscribe();
+    httpTesting.expectOne('api/cephfs/2/ls_dir?depth=2&path=%2Fsome%2Fpath');
+  });
 });
index cf13fab4fcbf18c91c77dd6b6f954b56d3781361..997ba3ce73f0e892bc01b54e02b4d6b9d5855161 100644 (file)
@@ -1,6 +1,9 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
+import { Observable } from 'rxjs';
+
+import { CephfsDir } from '../models/cephfs-directory-models';
 import { ApiModule } from './api.module';
 
 @Injectable({
@@ -15,6 +18,14 @@ export class CephfsService {
     return this.http.get(`${this.baseURL}`);
   }
 
+  lsDir(id, path?): Observable<CephfsDir[]> {
+    let apiPath = `${this.baseURL}/${id}/ls_dir?depth=2`;
+    if (path) {
+      apiPath += `&path=${encodeURIComponent(path)}`;
+    }
+    return this.http.get<CephfsDir[]>(apiPath);
+  }
+
   getCephfs(id) {
     return this.http.get(`${this.baseURL}/${id}`);
   }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts
new file mode 100644 (file)
index 0000000..f584d1f
--- /dev/null
@@ -0,0 +1,21 @@
+import { TreeStatus } from '@swimlane/ngx-datatable';
+
+export class CephfsSnapshot {
+  name: string;
+  path: string;
+  created: string;
+}
+
+export class CephfsQuotas {
+  max_bytes: number;
+  max_files: number;
+}
+
+export class CephfsDir {
+  name: string;
+  path: string;
+  quotas: CephfsQuotas;
+  snapshots: CephfsSnapshot[];
+  parent: string;
+  treeStatus?: TreeStatus; // Needed for table tree view
+}
index d5ea649cd6d420e4bdf5975d36272a16edd36d94..3dfcebc7fef8d8ed2af5077b18801bcf001dc34f 100644 (file)
@@ -167,7 +167,7 @@ class CephFS(object):
             dent = self.cfs.readdir(d)
             while dent:
                 if dent.is_dir():
-                    if dent.d_name not in [b'.', b'..']:
+                    if dent.d_name not in [b'.', b'..'] and not dent.d_name.startswith(b'_'):
                         snapshot_path = os.path.join(path, dent.d_name)
                         stat = self.cfs.stat(snapshot_path)
                         result.append({