]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Add UI for NFS List and Detail
authorTiago Melo <tspmelo@gmail.com>
Thu, 4 Oct 2018 16:20:10 +0000 (17:20 +0100)
committerTiago Melo <tmelo@suse.com>
Thu, 14 Feb 2019 10:29:14 +0000 (10:29 +0000)
Signed-off-by: Tiago Melo <tmelo@suse.com>
12 files changed:
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/ceph.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html

index fcb99c2f0d0fc9139bb0badddae22e352704e688..ecd38e4330b553684de47e57e41fe6a3e3854eba 100644 (file)
@@ -17,6 +17,8 @@ import { MonitorComponent } from './ceph/cluster/monitor/monitor.component';
 import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component';
 import { PrometheusListComponent } from './ceph/cluster/prometheus/prometheus-list/prometheus-list.component';
 import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
+import { NfsFormComponent } from './ceph/nfs/nfs-form/nfs-form.component';
+import { NfsListComponent } from './ceph/nfs/nfs-list/nfs-list.component';
 import { PerformanceCounterComponent } from './ceph/performance-counter/performance-counter/performance-counter.component';
 import { PoolFormComponent } from './ceph/pool/pool-form/pool-form.component';
 import { PoolListComponent } from './ceph/pool/pool-list/pool-list.component';
index 1887a7dd4036ae847cea0246da50e1fde23ba5af..1ba6085070ed9c9d9e10532a635de3510de00f89 100644 (file)
@@ -6,6 +6,7 @@ import { BlockModule } from './block/block.module';
 import { CephfsModule } from './cephfs/cephfs.module';
 import { ClusterModule } from './cluster/cluster.module';
 import { DashboardModule } from './dashboard/dashboard.module';
+import { NfsModule } from './nfs/nfs.module';
 import { PerformanceCounterModule } from './performance-counter/performance-counter.module';
 import { PoolModule } from './pool/pool.module';
 import { RgwModule } from './rgw/rgw.module';
@@ -20,6 +21,7 @@ import { RgwModule } from './rgw/rgw.module';
     BlockModule,
     PoolModule,
     CephfsModule,
+    NfsModule,
     SharedModule
   ],
   declarations: []
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.html
new file mode 100644 (file)
index 0000000..1a1a74a
--- /dev/null
@@ -0,0 +1,7 @@
+<tabset *ngIf="selection?.hasSingleSelection">
+  <tab heading="Details"
+       i18n-heading>
+    <cd-table-key-value [data]="data">
+    </cd-table-key-value>
+  </tab>
+</tabset>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.spec.ts
new file mode 100644 (file)
index 0000000..d5a38eb
--- /dev/null
@@ -0,0 +1,98 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import * as _ from 'lodash';
+import { TabsModule } from 'ngx-bootstrap/tabs';
+
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { SharedModule } from '../../../shared/shared.module';
+import { NfsDetailsComponent } from './nfs-details.component';
+
+describe('NfsDetailsComponent', () => {
+  let component: NfsDetailsComponent;
+  let fixture: ComponentFixture<NfsDetailsComponent>;
+
+  configureTestBed({
+    declarations: [NfsDetailsComponent],
+    imports: [SharedModule, TabsModule.forRoot(), HttpClientTestingModule],
+    providers: i18nProviders
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NfsDetailsComponent);
+    component = fixture.componentInstance;
+
+    component.selection = new CdTableSelection();
+    component.selection.selected = [
+      {
+        export_id: 1,
+        path: '/qwe',
+        fsal: { name: 'CEPH', user_id: 'fs', fs_name: 1 },
+        cluster_id: 'cluster1',
+        daemons: ['node1', 'node2'],
+        pseudo: '/qwe',
+        tag: 'asd',
+        access_type: 'RW',
+        squash: 'no_root_squash',
+        protocols: [3, 4],
+        transports: ['TCP', 'UDP'],
+        clients: [],
+        id: 'cluster1:1',
+        state: 'LOADING'
+      }
+    ];
+    component.selection.update();
+
+    fixture.detectChanges();
+  });
+
+  beforeEach(() => {});
+
+  it('should create', () => {
+    component.ngOnChanges();
+    expect(component.data).toBeTruthy();
+  });
+
+  it('should prepare data', () => {
+    component.ngOnChanges();
+    expect(component.data).toEqual({
+      'Access Type': 'RW',
+      'CephFS Filesystem': 1,
+      'CephFS User': 'fs',
+      Cluster: 'cluster1',
+      Daemons: ['node1', 'node2'],
+      'NFS Protocol': ['NFSv3', 'NFSv4'],
+      Path: '/qwe',
+      Pseudo: '/qwe',
+      'Security Label': undefined,
+      Squash: 'no_root_squash',
+      'Storage Backend': 'CephFS',
+      Transport: ['TCP', 'UDP']
+    });
+  });
+
+  it('should prepare data if RGW', () => {
+    const newData = _.assignIn(component.selection.first(), {
+      fsal: {
+        name: 'RGW',
+        rgw_user_id: 'rgw_user_id'
+      }
+    });
+    component.selection.selected = [newData];
+    component.selection.update();
+    component.ngOnChanges();
+    expect(component.data).toEqual({
+      'Access Type': 'RW',
+      Cluster: 'cluster1',
+      Daemons: ['node1', 'node2'],
+      'NFS Protocol': ['NFSv3', 'NFSv4'],
+      'Object Gateway User': 'rgw_user_id',
+      Path: '/qwe',
+      Pseudo: '/qwe',
+      Squash: 'no_root_squash',
+      'Storage Backend': 'Object Gateway',
+      Transport: ['TCP', 'UDP']
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-details/nfs-details.component.ts
new file mode 100644 (file)
index 0000000..c3ca8ba
--- /dev/null
@@ -0,0 +1,48 @@
+import { Component, Input, OnChanges } from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import * as _ from 'lodash';
+
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+
+@Component({
+  selector: 'cd-nfs-details',
+  templateUrl: './nfs-details.component.html',
+  styleUrls: ['./nfs-details.component.scss']
+})
+export class NfsDetailsComponent implements OnChanges {
+  @Input()
+  selection: CdTableSelection;
+
+  selectedItem: any;
+  data: any;
+
+  constructor(private i18n: I18n) {}
+
+  ngOnChanges() {
+    if (this.selection.hasSelection) {
+      this.selectedItem = this.selection.first();
+      this.data = {};
+      this.data[this.i18n('Cluster')] = this.selectedItem.cluster_id;
+      this.data[this.i18n('Daemons')] = this.selectedItem.daemons;
+      this.data[this.i18n('NFS Protocol')] = this.selectedItem.protocols.map(
+        (protocol) => 'NFSv' + protocol
+      );
+      this.data[this.i18n('Pseudo')] = this.selectedItem.pseudo;
+      this.data[this.i18n('Access Type')] = this.selectedItem.access_type;
+      this.data[this.i18n('Squash')] = this.selectedItem.squash;
+      this.data[this.i18n('Transport')] = this.selectedItem.transports;
+      this.data[this.i18n('Path')] = this.selectedItem.path;
+
+      if (this.selectedItem.fsal.name === 'CEPH') {
+        this.data[this.i18n('Storage Backend')] = this.i18n('CephFS');
+        this.data[this.i18n('CephFS User')] = this.selectedItem.fsal.user_id;
+        this.data[this.i18n('CephFS Filesystem')] = this.selectedItem.fsal.fs_name;
+        this.data[this.i18n('Security Label')] = this.selectedItem.fsal.sec_label_xattr;
+      } else {
+        this.data[this.i18n('Storage Backend')] = this.i18n('Object Gateway');
+        this.data[this.i18n('Object Gateway User')] = this.selectedItem.fsal.rgw_user_id;
+      }
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.html
new file mode 100644 (file)
index 0000000..d41cf65
--- /dev/null
@@ -0,0 +1,33 @@
+<cd-info-panel *ngIf="true"
+               title="Orchestrator not available"
+               i18n-title
+               i18n>To apply any changes you have to restart the Ganisha services.</cd-info-panel>
+
+<cd-table #table
+          [data]="exports"
+          columnMode="flex"
+          [columns]="columns"
+          identifier="id"
+          forceIdentifier="true"
+          selectionType="single"
+          (updateSelection)="updateSelection($event)">
+  <div class="table-actions btn-toolbar">
+    <cd-table-actions class="btn-group"
+                      [permission]="permission"
+                      [selection]="selection"
+                      [tableActions]="tableActions">
+    </cd-table-actions>
+  </div>
+
+  <cd-nfs-details cdTableDetail
+                  [selection]="selection">
+  </cd-nfs-details>
+</cd-table>
+
+<ng-template #nfsFsal
+             let-value="value">
+  <ng-container *ngIf="value.name==='CEPH'"
+                i18n>CephFS</ng-container>
+  <ng-container *ngIf="value.name==='RGW'"
+                i18n>Object Gateway</ng-container>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.spec.ts
new file mode 100644 (file)
index 0000000..056e6b4
--- /dev/null
@@ -0,0 +1,328 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { TabsModule } from 'ngx-bootstrap/tabs';
+import { BehaviorSubject, of } from 'rxjs';
+
+import {
+  configureTestBed,
+  i18nProviders,
+  PermissionHelper
+} from '../../../../testing/unit-test-helper';
+import { NfsService } from '../../../shared/api/nfs.service';
+import { TableActionsComponent } from '../../../shared/datatable/table-actions/table-actions.component';
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { SummaryService } from '../../../shared/services/summary.service';
+import { TaskListService } from '../../../shared/services/task-list.service';
+import { SharedModule } from '../../../shared/shared.module';
+import { NfsDetailsComponent } from '../nfs-details/nfs-details.component';
+import { NfsListComponent } from './nfs-list.component';
+
+describe('NfsListComponent', () => {
+  let component: NfsListComponent;
+  let fixture: ComponentFixture<NfsListComponent>;
+  let summaryService: SummaryService;
+  let nfsService: NfsService;
+  let httpTesting: HttpTestingController;
+
+  const refresh = (data) => {
+    summaryService['summaryDataSource'].next(data);
+  };
+
+  configureTestBed(
+    {
+      declarations: [NfsListComponent, NfsDetailsComponent],
+      imports: [
+        HttpClientTestingModule,
+        RouterTestingModule,
+        SharedModule,
+        ToastModule.forRoot(),
+        TabsModule.forRoot()
+      ],
+      providers: [TaskListService, i18nProviders]
+    },
+    true
+  );
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NfsListComponent);
+    component = fixture.componentInstance;
+    summaryService = TestBed.get(SummaryService);
+    nfsService = TestBed.get(NfsService);
+    httpTesting = TestBed.get(HttpTestingController);
+
+    // this is needed because summaryService isn't being reset after each test.
+    summaryService['summaryDataSource'] = new BehaviorSubject(null);
+    summaryService['summaryData$'] = summaryService['summaryDataSource'].asObservable();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('after ngOnInit', () => {
+    beforeEach(() => {
+      fixture.detectChanges();
+      spyOn(nfsService, 'list').and.callThrough();
+      httpTesting.expectOne('api/nfs-ganesha/daemon').flush([]);
+      httpTesting.expectOne('api/summary');
+    });
+
+    afterEach(() => {
+      httpTesting.verify();
+    });
+
+    it('should load exports on init', () => {
+      refresh({});
+      httpTesting.expectOne('api/nfs-ganesha/export');
+      expect(nfsService.list).toHaveBeenCalled();
+    });
+
+    it('should not load images on init because no data', () => {
+      refresh(undefined);
+      expect(nfsService.list).not.toHaveBeenCalled();
+    });
+
+    it('should call error function on init when summary service fails', () => {
+      spyOn(component.table, 'reset');
+      summaryService['summaryDataSource'].error(undefined);
+      expect(component.table.reset).toHaveBeenCalled();
+    });
+  });
+
+  describe('handling of executing tasks', () => {
+    let exports: any[];
+
+    const addExport = (export_id) => {
+      const model = {
+        export_id: export_id,
+        path: 'path_' + export_id,
+        fsal: 'fsal_' + export_id,
+        cluster_id: 'cluster_' + export_id
+      };
+      exports.push(model);
+    };
+
+    const addTask = (name: string, export_id: string) => {
+      const task = new ExecutingTask();
+      task.name = name;
+      switch (task.name) {
+        case 'nfs/create':
+          task.metadata = {
+            path: 'path_' + export_id,
+            fsal: 'fsal_' + export_id,
+            cluster_id: 'cluster_' + export_id
+          };
+          break;
+        default:
+          task.metadata = {
+            cluster_id: 'cluster_' + export_id,
+            export_id: export_id
+          };
+          break;
+      }
+      summaryService.addRunningTask(task);
+    };
+
+    const expectExportTasks = (expo: any, executing: string) => {
+      expect(expo.cdExecuting).toEqual(executing);
+    };
+
+    beforeEach(() => {
+      exports = [];
+      addExport('a');
+      addExport('b');
+      addExport('c');
+      component.exports = exports;
+      refresh({ executing_tasks: [], finished_tasks: [] });
+      spyOn(nfsService, 'list').and.callFake(() => of(exports));
+      fixture.detectChanges();
+
+      const req = httpTesting.expectOne('api/nfs-ganesha/daemon');
+      req.flush([]);
+    });
+
+    it('should gets all exports without tasks', () => {
+      expect(component.exports.length).toBe(3);
+      expect(component.exports.every((expo) => !expo.cdExecuting)).toBeTruthy();
+    });
+
+    it('should add a new export from a task', fakeAsync(() => {
+      addTask('nfs/create', 'd');
+      tick();
+      expect(component.exports.length).toBe(4);
+      expectExportTasks(component.exports[0], undefined);
+      expectExportTasks(component.exports[1], undefined);
+      expectExportTasks(component.exports[2], undefined);
+      expectExportTasks(component.exports[3], 'Creating');
+    }));
+
+    it('should show when an existing export is being modified', () => {
+      addTask('nfs/edit', 'a');
+      addTask('nfs/delete', 'b');
+      expect(component.exports.length).toBe(3);
+      expectExportTasks(component.exports[0], 'Updating');
+      expectExportTasks(component.exports[1], 'Deleting');
+    });
+  });
+
+  describe('show action buttons and drop down actions depending on permissions', () => {
+    let tableActions: TableActionsComponent;
+    let scenario: { fn; empty; single };
+    let permissionHelper: PermissionHelper;
+
+    const getTableActionComponent = (): TableActionsComponent => {
+      fixture.detectChanges();
+      return fixture.debugElement.query(By.directive(TableActionsComponent)).componentInstance;
+    };
+
+    beforeEach(() => {
+      permissionHelper = new PermissionHelper(component.permission, () =>
+        getTableActionComponent()
+      );
+      scenario = {
+        fn: () => tableActions.getCurrentButton().name,
+        single: 'Edit',
+        empty: 'Add'
+      };
+    });
+
+    describe('with all', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(1, 1, 1);
+      });
+
+      it(`shows 'Edit' for single selection else 'Add' as main action`, () =>
+        permissionHelper.testScenarios(scenario));
+
+      it('shows all actions', () => {
+        expect(tableActions.tableActions.length).toBe(3);
+        expect(tableActions.tableActions).toEqual(component.tableActions);
+      });
+    });
+
+    describe('with read, create and update', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(1, 1, 0);
+      });
+
+      it(`shows 'Edit' for single selection else 'Add' as main action`, () =>
+        permissionHelper.testScenarios(scenario));
+
+      it(`shows all actions except for 'Delete'`, () => {
+        expect(tableActions.tableActions.length).toBe(2);
+        component.tableActions.pop();
+        expect(tableActions.tableActions).toEqual(component.tableActions);
+      });
+    });
+
+    describe('with read, create and delete', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(1, 0, 1);
+      });
+
+      it(`shows 'Delete' for single selection else 'Add' as main action`, () => {
+        scenario.single = 'Delete';
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows 'Add', and 'Delete'  action`, () => {
+        expect(tableActions.tableActions.length).toBe(2);
+        expect(tableActions.tableActions).toEqual([
+          component.tableActions[0],
+          component.tableActions[2]
+        ]);
+      });
+    });
+
+    describe('with read, edit and delete', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 1, 1);
+      });
+
+      it(`shows always 'Edit' as main action`, () => {
+        scenario.empty = 'Edit';
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows 'Edit' and 'Delete' actions`, () => {
+        expect(tableActions.tableActions.length).toBe(2);
+        expect(tableActions.tableActions).toEqual([
+          component.tableActions[1],
+          component.tableActions[2]
+        ]);
+      });
+    });
+
+    describe('with read and create', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(1, 0, 0);
+      });
+
+      it(`always shows 'Add' as main action`, () => {
+        scenario.single = 'Add';
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows 'Add' action`, () => {
+        expect(tableActions.tableActions.length).toBe(1);
+        expect(tableActions.tableActions).toEqual([component.tableActions[0]]);
+      });
+    });
+
+    describe('with read and edit', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 1, 0);
+      });
+
+      it(`shows always 'Edit' as main action`, () => {
+        scenario.empty = 'Edit';
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows 'Edit' action`, () => {
+        expect(tableActions.tableActions.length).toBe(1);
+        expect(tableActions.tableActions).toEqual([component.tableActions[1]]);
+      });
+    });
+
+    describe('with read and delete', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 0, 1);
+      });
+
+      it(`shows always 'Delete' as main action`, () => {
+        scenario.single = 'Delete';
+        scenario.empty = 'Delete';
+        permissionHelper.testScenarios(scenario);
+      });
+
+      it(`shows 'Delete' action`, () => {
+        expect(tableActions.tableActions.length).toBe(1);
+        expect(tableActions.tableActions).toEqual([component.tableActions[2]]);
+      });
+    });
+
+    describe('with only read', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 0, 0);
+      });
+
+      it('shows no main action', () => {
+        permissionHelper.testScenarios({
+          fn: () => tableActions.getCurrentButton(),
+          single: undefined,
+          empty: undefined
+        });
+      });
+
+      it('shows no actions', () => {
+        expect(tableActions.tableActions.length).toBe(0);
+        expect(tableActions.tableActions).toEqual([]);
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs-list/nfs-list.component.ts
new file mode 100644 (file)
index 0000000..fb00a8a
--- /dev/null
@@ -0,0 +1,213 @@
+import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import * as _ from 'lodash';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
+import { Subscription } from 'rxjs';
+
+import { NfsService } from '../../../shared/api/nfs.service';
+import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { TableComponent } from '../../../shared/datatable/table/table.component';
+import { CellTemplate } from '../../../shared/enum/cell-template.enum';
+import { ViewCacheStatus } from '../../../shared/enum/view-cache-status.enum';
+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 { FinishedTask } from '../../../shared/models/finished-task';
+import { Permission } from '../../../shared/models/permissions';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { TaskListService } from '../../../shared/services/task-list.service';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+
+@Component({
+  selector: 'cd-nfs-list',
+  templateUrl: './nfs-list.component.html',
+  styleUrls: ['./nfs-list.component.scss'],
+  providers: [TaskListService]
+})
+export class NfsListComponent implements OnInit, OnDestroy {
+  @ViewChild('nfsState')
+  nfsState: TemplateRef<any>;
+  @ViewChild('nfsFsal')
+  nfsFsal: TemplateRef<any>;
+
+  @ViewChild('table')
+  table: TableComponent;
+
+  columns: CdTableColumn[];
+  permission: Permission;
+  selection = new CdTableSelection();
+  summaryDataSubscription: Subscription;
+  viewCacheStatus: any;
+  exports: any[];
+  tableActions: CdTableAction[];
+  isDefaultCluster = false;
+
+  modalRef: BsModalRef;
+
+  builders = {
+    'nfs/create': (metadata) => {
+      return {
+        path: metadata['path'],
+        cluster_id: metadata['cluster_id'],
+        fsal: metadata['fsal']
+      };
+    }
+  };
+
+  constructor(
+    private authStorageService: AuthStorageService,
+    private i18n: I18n,
+    private modalService: BsModalService,
+    private nfsService: NfsService,
+    private taskListService: TaskListService,
+    private taskWrapper: TaskWrapperService
+  ) {
+    this.permission = this.authStorageService.getPermissions().nfs;
+    const getNfsUri = () =>
+      this.selection.first() &&
+      `${encodeURI(this.selection.first().cluster_id)}/${encodeURI(
+        this.selection.first().export_id
+      )}`;
+
+    const addAction: CdTableAction = {
+      permission: 'create',
+      icon: 'fa-plus',
+      routerLink: () => '/nfs/add',
+      canBePrimary: (selection: CdTableSelection) => !selection.hasSingleSelection,
+      name: this.i18n('Add')
+    };
+
+    const editAction: CdTableAction = {
+      permission: 'update',
+      icon: 'fa-pencil',
+      routerLink: () => `/nfs/edit/${getNfsUri()}`,
+      name: this.i18n('Edit')
+    };
+
+    const deleteAction: CdTableAction = {
+      permission: 'delete',
+      icon: 'fa-times',
+      click: () => this.deleteNfsModal(),
+      name: this.i18n('Delete')
+    };
+
+    this.tableActions = [addAction, editAction, deleteAction];
+  }
+
+  ngOnInit() {
+    this.columns = [
+      {
+        name: this.i18n('Export'),
+        prop: 'path',
+        flexGrow: 2,
+        cellTransformation: CellTemplate.executing
+      },
+      {
+        name: this.i18n('Cluster'),
+        prop: 'cluster_id',
+        flexGrow: 2
+      },
+      {
+        name: this.i18n('Daemons'),
+        prop: 'daemons',
+        flexGrow: 2
+      },
+      {
+        name: this.i18n('Storage Backend'),
+        prop: 'fsal',
+        flexGrow: 2,
+        cellTemplate: this.nfsFsal
+      },
+      {
+        name: this.i18n('Access Type'),
+        prop: 'access_type',
+        flexGrow: 2
+      }
+    ];
+
+    this.nfsService.daemon().subscribe(
+      (daemons: any) => {
+        const clusters = _(daemons)
+          .map((daemon) => daemon.cluster_id)
+          .uniq()
+          .value();
+
+        this.isDefaultCluster = clusters.length === 1 && clusters[0] === '_default_';
+        this.columns[1].isHidden = this.isDefaultCluster;
+        if (this.table) {
+          this.table.updateColumns();
+        }
+
+        this.taskListService.init(
+          () => this.nfsService.list(),
+          (resp) => this.prepareResponse(resp),
+          (exports) => (this.exports = exports),
+          () => this.onFetchError(),
+          this.taskFilter,
+          this.itemFilter,
+          this.builders
+        );
+      },
+      (error) => {
+        this.onFetchError();
+      }
+    );
+  }
+
+  ngOnDestroy() {
+    if (this.summaryDataSubscription) {
+      this.summaryDataSubscription.unsubscribe();
+    }
+  }
+
+  prepareResponse(resp: any): any[] {
+    let result = [];
+    resp.forEach((nfs) => {
+      nfs.id = `${nfs.cluster_id}:${nfs.export_id}`;
+      nfs.state = 'LOADING';
+      result = result.concat(nfs);
+    });
+
+    return result;
+  }
+
+  onFetchError() {
+    this.table.reset(); // Disable loading indicator.
+    this.viewCacheStatus = { status: ViewCacheStatus.ValueException };
+  }
+
+  itemFilter(entry, task) {
+    return (
+      entry.cluster_id === task.metadata['cluster_id'] &&
+      entry.export_id === task.metadata['export_id']
+    );
+  }
+
+  taskFilter(task) {
+    return ['nfs/create', 'nfs/delete', 'nfs/edit'].includes(task.name);
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  deleteNfsModal() {
+    const cluster_id = this.selection.first().cluster_id;
+    const export_id = this.selection.first().export_id;
+
+    this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+      initialState: {
+        itemDescription: this.i18n('NFS'),
+        submitActionObservable: () =>
+          this.taskWrapper.wrapTaskAroundCall({
+            task: new FinishedTask('nfs/delete', {
+              cluster_id: cluster_id,
+              export_id: export_id
+            }),
+            call: this.nfsService.delete(cluster_id, export_id)
+          })
+      }
+    });
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/nfs/nfs.module.ts
new file mode 100644 (file)
index 0000000..1a738c3
--- /dev/null
@@ -0,0 +1,26 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+
+import { TabsModule } from 'ngx-bootstrap/tabs';
+import { TypeaheadModule } from 'ngx-bootstrap/typeahead';
+
+import { SharedModule } from '../../shared/shared.module';
+import { NfsDetailsComponent } from './nfs-details/nfs-details.component';
+import { NfsFormClientComponent } from './nfs-form-client/nfs-form-client.component';
+import { NfsFormComponent } from './nfs-form/nfs-form.component';
+import { NfsListComponent } from './nfs-list/nfs-list.component';
+
+@NgModule({
+  imports: [
+    ReactiveFormsModule,
+    RouterModule,
+    SharedModule,
+    TabsModule.forRoot(),
+    CommonModule,
+    TypeaheadModule.forRoot()
+  ],
+  declarations: [NfsListComponent, NfsDetailsComponent, NfsFormComponent, NfsFormClientComponent]
+})
+export class NfsModule {}
index 52ac52a110d85374cf18df213eff3cd29a546810..60eeb1e6b14628407e7138e0fbd38e10c4cd660e 100644 (file)
                class="dropdown-item"
                routerLink="/block/iscsi">iSCSI</a>
           </li>
-
         </ul>
       </li>
 
+      <!-- NFS -->
+      <li routerLinkActive="active"
+          class="tc_menuitem tc_menuitem_nfs"
+          *ngIf="permissions?.nfs?.read">
+        <a i18n
+           routerLink="/nfs">NFS</a>
+      </li>
+
       <!-- Filesystem -->
       <li routerLinkActive="active"
           class="tc_menuitem tc_menuitem_cephs"