From: Stephan Müller Date: Wed, 21 Aug 2019 08:34:02 +0000 (+0200) Subject: mgr/dashboard: CephFS directory component X-Git-Tag: v15.1.0~1057^2 X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=a3a58c691f3e1ea9a7bf2ecb0305edc0d66fb3b0;p=ceph-ci.git mgr/dashboard: CephFS directory component 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 --- diff --git a/qa/tasks/mgr/dashboard/test_cephfs.py b/qa/tasks/mgr/dashboard/test_cephfs.py index 8eba3327e4d..bd8666ea499 100644 --- a/qa/tasks/mgr/dashboard/test_cephfs.py +++ b/qa/tasks/mgr/dashboard/test_cephfs.py @@ -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') diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 853bea2d148..d0019b8922b 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -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 index 00000000000..a8acd26c51c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html @@ -0,0 +1,41 @@ +
+
+ + + + + +
+ + +
+ + + {{value}} + 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 index 00000000000..b350a045074 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.scss @@ -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 index 00000000000..90d283e320c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts @@ -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; + 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 index 00000000000..b70286f5657 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts @@ -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; + + @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]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html index abbc9bec734..c2fc3e2eb29 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html @@ -13,6 +13,11 @@ (triggerApiUpdate)="refresh()"> + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.spec.ts index e0b3258b90f..64170da229a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.spec.ts @@ -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] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts index 830c2155339..fedb1154f7e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts @@ -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 {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts index 04be758665f..5910f598642 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts @@ -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'); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts index cf13fab4fcb..997ba3ce73f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts @@ -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 { + let apiPath = `${this.baseURL}/${id}/ls_dir?depth=2`; + if (path) { + apiPath += `&path=${encodeURIComponent(path)}`; + } + return this.http.get(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 index 00000000000..f584d1f4bdf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts @@ -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 +} diff --git a/src/pybind/mgr/dashboard/services/cephfs.py b/src/pybind/mgr/dashboard/services/cephfs.py index d5ea649cd6d..3dfcebc7fef 100644 --- a/src/pybind/mgr/dashboard/services/cephfs.py +++ b/src/pybind/mgr/dashboard/services/cephfs.py @@ -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({