]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add read-only UI for iSCSI
authorTiago Melo <tmelo@suse.com>
Fri, 14 Dec 2018 14:46:20 +0000 (14:46 +0000)
committerTiago Melo <tmelo@suse.com>
Tue, 5 Feb 2019 12:19:52 +0000 (12:19 +0000)
Signed-off-by: Ricardo Marques <rimarques@suse.com>
Signed-off-by: Tiago Melo <tmelo@suse.com>
18 files changed:
src/pybind/mgr/dashboard/frontend/e2e/block/iscsi.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts [new file with mode: 0644]

index ebf3bb52723dcc2e81ee20acbc62b69d6c0ee496..8a3a03b06371d3c4b485f15ce6ae2ce919a23463 100644 (file)
@@ -14,6 +14,6 @@ describe('Iscsi Page', () => {
 
   it('should open and show breadcrumb', () => {
     page.navigateTo();
-    expect(Helper.getBreadcrumbText()).toEqual('iSCSI');
+    expect(Helper.getBreadcrumbText()).toEqual('Overview');
   });
 });
index fddc917b75947db25f7745087b5e2678322ee924..3523d0b132c4f4cfbcbc3e1d01a9cc54fd9978c0 100644 (file)
@@ -1,6 +1,7 @@
 import { NgModule } from '@angular/core';
 import { ActivatedRouteSnapshot, RouterModule, Routes } from '@angular/router';
 
+import { IscsiTargetListComponent } from './ceph/block/iscsi-target-list/iscsi-target-list.component';
 import { IscsiComponent } from './ceph/block/iscsi/iscsi.component';
 import { OverviewComponent as RbdMirroringComponent } from './ceph/block/mirroring/overview/overview.component';
 import { RbdFormComponent } from './ceph/block/rbd-form/rbd-form.component';
@@ -173,7 +174,30 @@ const routes: Routes = [
         component: RbdMirroringComponent,
         data: { breadcrumbs: 'Mirroring' }
       },
-      { path: 'iscsi', component: IscsiComponent, data: { breadcrumbs: 'iSCSI' } }
+      // iSCSI
+      {
+        path: 'iscsi',
+        data: { breadcrumbs: 'iSCSI' },
+        children: [
+          {
+            path: '',
+            redirectTo: 'overview',
+            pathMatch: 'full'
+          },
+          {
+            path: 'overview',
+            data: { breadcrumbs: 'Overview' },
+            children: [{ path: '', component: IscsiComponent }]
+          },
+          {
+            path: 'targets',
+            data: { breadcrumbs: 'Targets' },
+            children: [
+              { path: '', component: IscsiTargetListComponent }
+            ]
+          }
+        ]
+      }
     ]
   },
   // Filesystems
index 8ca6099e58c1e20c8ae6ed8b84084f14057a09a0..651b1d5c1ab77f6cfd43af3606561660298aff35 100644 (file)
@@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
 import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 import { RouterModule } from '@angular/router';
 
+import { TreeModule } from 'ng2-tree';
 import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
 import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
 import { ModalModule } from 'ngx-bootstrap/modal';
@@ -11,6 +12,8 @@ import { TabsModule } from 'ngx-bootstrap/tabs';
 import { TooltipModule } from 'ngx-bootstrap/tooltip';
 
 import { SharedModule } from '../../shared/shared.module';
+import { IscsiTabsComponent } from './iscsi-tabs/iscsi-tabs.component';
+import { IscsiTargetListComponent } from './iscsi-target-list/iscsi-target-list.component';
 import { IscsiComponent } from './iscsi/iscsi.component';
 import { MirroringModule } from './mirroring/mirroring.module';
 import { RbdDetailsComponent } from './rbd-details/rbd-details.component';
@@ -23,6 +26,7 @@ import { RbdTrashListComponent } from './rbd-trash-list/rbd-trash-list.component
 import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-move-modal.component';
 import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal/rbd-trash-purge-modal.component';
 import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-trash-restore-modal.component';
+import { IscsiTargetDetailsComponent } from './iscsi-target-details/iscsi-target-details.component';
 
 @NgModule({
   entryComponents: [
@@ -30,7 +34,8 @@ import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-tra
     RbdSnapshotFormComponent,
     RbdTrashMoveModalComponent,
     RbdTrashRestoreModalComponent,
-    RbdTrashPurgeModalComponent
+    RbdTrashPurgeModalComponent,
+    IscsiTargetDetailsComponent
   ],
   imports: [
     CommonModule,
@@ -44,11 +49,14 @@ import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-tra
     TooltipModule.forRoot(),
     ModalModule.forRoot(),
     SharedModule,
-    RouterModule
+    RouterModule,
+    TreeModule
   ],
   declarations: [
     RbdListComponent,
     IscsiComponent,
+    IscsiTabsComponent,
+    IscsiTargetListComponent,
     RbdDetailsComponent,
     RbdFormComponent,
     RbdSnapshotListComponent,
@@ -57,7 +65,8 @@ import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-tra
     RbdTrashMoveModalComponent,
     RbdImagesComponent,
     RbdTrashRestoreModalComponent,
-    RbdTrashPurgeModalComponent
+    RbdTrashPurgeModalComponent,
+    IscsiTargetDetailsComponent
   ]
 })
 export class BlockModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.html
new file mode 100644 (file)
index 0000000..ac4bcf1
--- /dev/null
@@ -0,0 +1,12 @@
+<tabset>
+  <tab heading="Overview"
+       i18n-heading
+       [active]="url === '/block/iscsi/overview'"
+       (select)="navigateTo('/block/iscsi/overview')">
+  </tab>
+  <tab heading="Targets"
+       i18n-heading
+       [active]="url === '/block/iscsi/targets'"
+       (select)="navigateTo('/block/iscsi/targets')">
+  </tab>
+</tabset>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.spec.ts
new file mode 100644 (file)
index 0000000..e51c9fa
--- /dev/null
@@ -0,0 +1,28 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { TabsModule } from 'ngx-bootstrap/tabs';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../shared/shared.module';
+import { IscsiTabsComponent } from './iscsi-tabs.component';
+
+describe('IscsiTabsComponent', () => {
+  let component: IscsiTabsComponent;
+  let fixture: ComponentFixture<IscsiTabsComponent>;
+
+  configureTestBed({
+    imports: [SharedModule, TabsModule.forRoot(), RouterTestingModule],
+    declarations: [IscsiTabsComponent]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(IscsiTabsComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-tabs/iscsi-tabs.component.ts
new file mode 100644 (file)
index 0000000..b5dc3cf
--- /dev/null
@@ -0,0 +1,22 @@
+import { Component, OnInit } from '@angular/core';
+
+import { Router } from '@angular/router';
+
+@Component({
+  selector: 'cd-iscsi-tabs',
+  templateUrl: './iscsi-tabs.component.html',
+  styleUrls: ['./iscsi-tabs.component.scss']
+})
+export class IscsiTabsComponent implements OnInit {
+  url: string;
+
+  constructor(private router: Router) {}
+
+  ngOnInit() {
+    this.url = this.router.url;
+  }
+
+  navigateTo(url) {
+    this.router.navigate([url]);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.html
new file mode 100644 (file)
index 0000000..b57089f
--- /dev/null
@@ -0,0 +1,35 @@
+<div class="col-sm-6 col-lg-6">
+  <legend i18n>iSCSI Topology</legend>
+  <tree [tree]="tree"
+        (nodeSelected)="onNodeSelected($event)">
+    <ng-template let-node>
+      <span class="node-name"
+            [innerHTML]="node.value"></span>
+      <span>&nbsp;</span>
+
+      <span class="label"
+            [ngClass]="{'label-success': ['in', 'up'].includes(node.status), 'label-danger': ['down', 'out'].includes(node.status)}">
+        {{ node.status }}
+      </span>
+    </ng-template>
+  </tree>
+</div>
+
+<div class="col-sm-6 col-lg-6 metadata"
+     *ngIf="data">
+  <legend>{{ title }}</legend>
+
+  <cd-table #detailTable
+            [data]="data"
+            columnMode="flex"
+            [columns]="columns"
+            [limit]="0">
+  </cd-table>
+</div>
+
+<ng-template #highlightTpl
+             let-row="row"
+             let-value="value">
+  <span *ngIf="row.default === undefined || row.default === row.current">{{ value }}</span>
+  <strong *ngIf="row.default !== undefined && row.default !== row.current">{{ value }}</strong>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.scss
new file mode 100644 (file)
index 0000000..202e7be
--- /dev/null
@@ -0,0 +1,3 @@
+::ng-deep tree .fa {
+  font-weight: unset !important;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.spec.ts
new file mode 100644 (file)
index 0000000..b855256
--- /dev/null
@@ -0,0 +1,176 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NodeEvent, Tree, TreeModule } from 'ng2-tree';
+
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { SharedModule } from '../../../shared/shared.module';
+import { IscsiTargetDetailsComponent } from './iscsi-target-details.component';
+
+describe('IscsiTargetDetailsComponent', () => {
+  let component: IscsiTargetDetailsComponent;
+  let fixture: ComponentFixture<IscsiTargetDetailsComponent>;
+
+  configureTestBed({
+    declarations: [IscsiTargetDetailsComponent],
+    imports: [TreeModule, SharedModule],
+    providers: [i18nProviders]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(IscsiTargetDetailsComponent);
+    component = fixture.componentInstance;
+
+    component.settings = {
+      config: { minimum_gateways: 2 },
+      disk_default_controls: {
+        hw_max_sectors: 1024,
+        max_data_area_mb: 8
+      },
+      target_default_controls: {
+        cmdsn_depth: 128,
+        dataout_timeout: 20
+      }
+    };
+    component.selection = new CdTableSelection();
+    component.selection.selected = [
+      {
+        target_iqn: 'iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw',
+        portals: [{ host: 'node1', ip: '192.168.100.201' }],
+        disks: [{ pool: 'rbd', image: 'disk_1', controls: { hw_max_sectors: 1 } }],
+        clients: [
+          {
+            client_iqn: 'iqn.1994-05.com.redhat:rh7-client',
+            luns: [{ pool: 'rbd', image: 'disk_1' }],
+            auth: {
+              user: 'myiscsiusername'
+            }
+          }
+        ],
+        groups: [],
+        target_controls: { dataout_timeout: 2 }
+      }
+    ];
+    component.selection.update();
+
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should empty data and generateTree when ngOnChanges is called', () => {
+    const tempData = [{ current: 'baz', default: 'bar', displayName: 'foo' }];
+    component.data = tempData;
+    fixture.detectChanges();
+
+    expect(component.data).toEqual(tempData);
+    expect(component.metadata).toEqual({});
+    expect(component.tree).toEqual(undefined);
+
+    component.ngOnChanges();
+
+    expect(component.data).toBeUndefined();
+    expect(component.metadata).toEqual({
+      'client_iqn.1994-05.com.redhat:rh7-client': { user: 'myiscsiusername' },
+      disk_rbd_disk_1: { hw_max_sectors: 1 },
+      root: { dataout_timeout: 2 }
+    });
+    expect(component.tree).toEqual({
+      children: [
+        {
+          children: [{ id: 'disk_rbd_disk_1', value: 'rbd/disk_1' }],
+          settings: {
+            cssClasses: { expanded: 'fa fa-fw fa-hdd-o fa-lg', leaf: 'fa fa-fw fa-hdd-o' },
+            selectionAllowed: false
+          },
+          value: 'Disks'
+        },
+        {
+          children: [{ value: 'node1:192.168.100.201' }],
+          settings: {
+            cssClasses: { expanded: 'fa fa-fw fa-server fa-lg', leaf: 'fa fa-fw fa-server fa-lg' },
+            selectionAllowed: false
+          },
+          value: 'Portals'
+        },
+        {
+          children: [
+            {
+              children: [
+                {
+                  id: 'disk_rbd_disk_1',
+                  settings: {
+                    cssClasses: { expanded: 'fa fa-fw fa-hdd-o fa-lg', leaf: 'fa fa-fw fa-hdd-o' }
+                  },
+                  value: 'rbd/disk_1'
+                }
+              ],
+              id: 'client_iqn.1994-05.com.redhat:rh7-client',
+              value: 'iqn.1994-05.com.redhat:rh7-client'
+            }
+          ],
+          settings: {
+            cssClasses: { expanded: 'fa fa-fw fa-user fa-lg', leaf: 'fa fa-fw fa-user' },
+            selectionAllowed: false
+          },
+          value: 'Initiators'
+        },
+        {
+          children: [],
+          settings: {
+            cssClasses: { expanded: 'fa fa-fw fa-users fa-lg', leaf: 'fa fa-fw fa-users' },
+            selectionAllowed: false
+          },
+          value: 'Groups'
+        }
+      ],
+      id: 'root',
+      settings: { cssClasses: { expanded: 'fa fa-fw fa-bullseye fa-lg' }, static: true },
+      value: 'iqn.2003-01.com.redhat.iscsi-gw:iscsi-igw'
+    });
+  });
+
+  describe('should update data when onNodeSelected is called', () => {
+    beforeEach(() => {
+      component.ngOnChanges();
+    });
+
+    it('with target selected', () => {
+      const tree = new Tree(component.tree);
+      const node = new NodeEvent(tree);
+      component.onNodeSelected(node);
+      expect(component.data).toEqual([
+        { current: 128, default: 128, displayName: 'cmdsn_depth' },
+        { current: 2, default: 20, displayName: 'dataout_timeout' }
+      ]);
+    });
+
+    it('with disk selected', () => {
+      const tree = new Tree(component.tree.children[0].children[0]);
+      const node = new NodeEvent(tree);
+      component.onNodeSelected(node);
+      expect(component.data).toEqual([
+        { current: 1, default: 1024, displayName: 'hw_max_sectors' },
+        { current: 8, default: 8, displayName: 'max_data_area_mb' }
+      ]);
+    });
+
+    it('with initiator selected', () => {
+      const tree = new Tree(component.tree.children[2].children[0]);
+      const node = new NodeEvent(tree);
+      component.onNodeSelected(node);
+      expect(component.data).toEqual([
+        { current: 'myiscsiusername', default: undefined, displayName: 'user' }
+      ]);
+    });
+
+    it('with any other selected', () => {
+      const tree = new Tree(component.tree.children[1].children[0]);
+      const node = new NodeEvent(tree);
+      component.onNodeSelected(node);
+      expect(component.data).toBeUndefined();
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-details/iscsi-target-details.component.ts
new file mode 100644 (file)
index 0000000..aa8878a
--- /dev/null
@@ -0,0 +1,263 @@
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import * as _ from 'lodash';
+import { NodeEvent, TreeModel } from 'ng2-tree';
+
+import { TableComponent } from '../../../shared/datatable/table/table.component';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+
+@Component({
+  selector: 'cd-iscsi-target-details',
+  templateUrl: './iscsi-target-details.component.html',
+  styleUrls: ['./iscsi-target-details.component.scss']
+})
+export class IscsiTargetDetailsComponent implements OnChanges, OnInit {
+  @Input()
+  selection: CdTableSelection;
+  @Input()
+  settings: any;
+
+  @ViewChild('highlightTpl')
+  highlightTpl: TemplateRef<any>;
+
+  private detailTable: TableComponent;
+  @ViewChild('detailTable')
+  set content(content: TableComponent) {
+    this.detailTable = content;
+    if (content) {
+      content.updateColumns();
+    }
+  }
+
+  columns: CdTableColumn[];
+  data: any;
+  metadata: any = {};
+  selectedItem: any;
+  title: string;
+  tree: TreeModel;
+
+  constructor(private i18n: I18n) {}
+
+  ngOnInit() {
+    this.columns = [
+      {
+        prop: 'displayName',
+        name: this.i18n('Name'),
+        flexGrow: 2,
+        cellTemplate: this.highlightTpl
+      },
+      {
+        prop: 'current',
+        name: this.i18n('Current'),
+        flexGrow: 1,
+        cellTemplate: this.highlightTpl
+      },
+      {
+        prop: 'default',
+        name: this.i18n('Default'),
+        flexGrow: 1,
+        cellTemplate: this.highlightTpl
+      }
+    ];
+  }
+
+  ngOnChanges() {
+    if (this.selection.hasSelection) {
+      this.selectedItem = this.selection.first();
+      this.generateTree();
+    }
+
+    this.data = undefined;
+  }
+
+  private generateTree() {
+    this.metadata = { root: this.selectedItem.target_controls };
+
+    const cssClasses = {
+      target: {
+        expanded: 'fa fa-fw fa-bullseye fa-lg'
+      },
+      initiators: {
+        expanded: 'fa fa-fw fa-user fa-lg',
+        leaf: 'fa fa-fw fa-user'
+      },
+      groups: {
+        expanded: 'fa fa-fw fa-users fa-lg',
+        leaf: 'fa fa-fw fa-users'
+      },
+      disks: {
+        expanded: 'fa fa-fw fa-hdd-o fa-lg',
+        leaf: 'fa fa-fw fa-hdd-o'
+      },
+      portals: {
+        expanded: 'fa fa-fw fa-server fa-lg',
+        leaf: 'fa fa-fw fa-server fa-lg'
+      }
+    };
+
+    const disks = [];
+    _.forEach(this.selectedItem.disks, (disk) => {
+      const id = 'disk_' + disk.pool + '_' + disk.image;
+      this.metadata[id] = disk.controls;
+      disks.push({
+        value: `${disk.pool}/${disk.image}`,
+        id: id
+      });
+    });
+
+    const portals = [];
+    _.forEach(this.selectedItem.portals, (portal) => {
+      portals.push({ value: `${portal.host}:${portal.ip}` });
+    });
+
+    const clients = [];
+    _.forEach(this.selectedItem.clients, (client) => {
+      this.metadata['client_' + client.client_iqn] = client.auth;
+
+      const luns = [];
+      client.luns.forEach((lun) => {
+        luns.push({
+          value: `${lun.pool}/${lun.image}`,
+          id: 'disk_' + lun.pool + '_' + lun.image,
+          settings: {
+            cssClasses: cssClasses.disks
+          }
+        });
+      });
+
+      clients.push({
+        value: client.client_iqn,
+        id: 'client_' + client.client_iqn,
+        children: luns
+      });
+    });
+
+    const groups = [];
+    _.forEach(this.selectedItem.groups, (group) => {
+      const luns = [];
+      group.disks.forEach((disk) => {
+        luns.push({
+          value: `${disk.pool}/${disk.image}`,
+          id: 'disk_' + disk.pool + '_' + disk.image
+        });
+      });
+
+      const initiators = [];
+      group.members.forEach((member) => {
+        initiators.push({
+          value: member,
+          id: 'client_' + member
+        });
+      });
+
+      groups.push({
+        value: group.group_id,
+        children: [
+          {
+            value: 'Disks',
+            children: luns,
+            settings: {
+              selectionAllowed: false,
+              cssClasses: cssClasses.disks
+            }
+          },
+          {
+            value: 'Initiators',
+            children: initiators,
+            settings: {
+              selectionAllowed: false,
+              cssClasses: cssClasses.initiators
+            }
+          }
+        ]
+      });
+    });
+
+    this.tree = {
+      value: this.selectedItem.target_iqn,
+      id: 'root',
+      settings: {
+        static: true,
+        cssClasses: cssClasses.target
+      },
+      children: [
+        {
+          value: 'Disks',
+          children: disks,
+          settings: {
+            selectionAllowed: false,
+            cssClasses: cssClasses.disks
+          }
+        },
+        {
+          value: 'Portals',
+          children: portals,
+          settings: {
+            selectionAllowed: false,
+            cssClasses: cssClasses.portals
+          }
+        },
+        {
+          value: 'Initiators',
+          children: clients,
+          settings: {
+            selectionAllowed: false,
+            cssClasses: cssClasses.initiators
+          }
+        },
+        {
+          value: 'Groups',
+          children: groups,
+          settings: {
+            selectionAllowed: false,
+            cssClasses: cssClasses.groups
+          }
+        }
+      ]
+    };
+  }
+
+  onNodeSelected(e: NodeEvent) {
+    if (e.node.id) {
+      this.title = e.node.value;
+      const tempData = this.metadata[e.node.id] || {};
+
+      if (e.node.id === 'root') {
+        this.columns[2].isHidden = false;
+        this.data = _.map(this.settings.target_default_controls, (value, key) => {
+          return {
+            displayName: key,
+            default: value,
+            current: tempData[key] || value
+          };
+        });
+      } else if (e.node.id.toString().startsWith('disk_')) {
+        this.columns[2].isHidden = false;
+        this.data = _.map(this.settings.disk_default_controls, (value, key) => {
+          return {
+            displayName: key,
+            default: value,
+            current: tempData[key] || value
+          };
+        });
+      } else {
+        this.columns[2].isHidden = true;
+        this.data = _.map(tempData, (value, key) => {
+          return {
+            displayName: key,
+            default: undefined,
+            current: value
+          };
+        });
+      }
+    } else {
+      this.data = undefined;
+    }
+
+    if (this.detailTable) {
+      this.detailTable.updateColumns();
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html
new file mode 100644 (file)
index 0000000..97f35fa
--- /dev/null
@@ -0,0 +1,30 @@
+<cd-iscsi-tabs></cd-iscsi-tabs>
+
+<cd-info-panel *ngIf="available === false"
+               title="iSCSI Targets not available"
+               i18n-title>
+  <ng-container i18n>Please consult the <a href="{{docsUrl}}"
+       target="_blank">documentation</a>
+    on how to configure and enable the iSCSI Targets management functionality.</ng-container>
+
+  <ng-container *ngIf="status">
+    <br>
+    <span i18n>Available information:</span>
+    <pre>{{ status }}</pre>
+  </ng-container>
+</cd-info-panel>
+
+<cd-table #table
+          *ngIf="available === true"
+          [data]="targets"
+          columnMode="flex"
+          [columns]="columns"
+          identifier="target_iqn"
+          forceIdentifier="true"
+          selectionType="single"
+          (updateSelection)="updateSelection($event)">
+  <cd-iscsi-target-details cdTableDetail
+                           *ngIf="selection.hasSingleSelection"
+                           [selection]="selection"
+                           [settings]="settings"></cd-iscsi-target-details>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.scss
new file mode 100644 (file)
index 0000000..1335bf2
--- /dev/null
@@ -0,0 +1,9 @@
+::ng-deep tabset.tabset > ul {
+  border-bottom: 1px solid #ddd;
+  float: left;
+  display: block;
+  margin-right: 20px;
+  border-bottom: 0;
+  border-right: 1px solid #ddd;
+  padding-right: 15px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.spec.ts
new file mode 100644 (file)
index 0000000..136373d
--- /dev/null
@@ -0,0 +1,42 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { TreeModule } from 'ng2-tree';
+import { TabsModule } from 'ngx-bootstrap/tabs';
+
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { TaskListService } from '../../../shared/services/task-list.service';
+import { SharedModule } from '../../../shared/shared.module';
+import { IscsiTabsComponent } from '../iscsi-tabs/iscsi-tabs.component';
+import { IscsiTargetDetailsComponent } from '../iscsi-target-details/iscsi-target-details.component';
+import { IscsiTargetListComponent } from './iscsi-target-list.component';
+
+describe('IscsiTargetListComponent', () => {
+  let component: IscsiTargetListComponent;
+  let fixture: ComponentFixture<IscsiTargetListComponent>;
+
+  configureTestBed({
+    imports: [
+      HttpClientTestingModule,
+      RouterTestingModule,
+      SharedModule,
+      TabsModule.forRoot(),
+      TreeModule,
+      ToastModule.forRoot()
+    ],
+    declarations: [IscsiTargetListComponent, IscsiTabsComponent, IscsiTargetDetailsComponent],
+    providers: [TaskListService, i18nProviders]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(IscsiTargetListComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.ts
new file mode 100644 (file)
index 0000000..21b1cb7
--- /dev/null
@@ -0,0 +1,118 @@
+import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import { Subscription } from 'rxjs';
+
+import { IscsiService } from '../../../shared/api/iscsi.service';
+import { TableComponent } from '../../../shared/datatable/table/table.component';
+import { CellTemplate } from '../../../shared/enum/cell-template.enum';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { Permissions } from '../../../shared/models/permissions';
+import { CephReleaseNamePipe } from '../../../shared/pipes/ceph-release-name.pipe';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { SummaryService } from '../../../shared/services/summary.service';
+import { TaskListService } from '../../../shared/services/task-list.service';
+
+@Component({
+  selector: 'cd-iscsi-target-list',
+  templateUrl: './iscsi-target-list.component.html',
+  styleUrls: ['./iscsi-target-list.component.scss'],
+  providers: [TaskListService]
+})
+export class IscsiTargetListComponent implements OnInit, OnDestroy {
+  @ViewChild(TableComponent)
+  table: TableComponent;
+
+  available: boolean = undefined;
+  columns: CdTableColumn[];
+  docsUrl: string;
+  modalRef: BsModalRef;
+  permissions: Permissions;
+  selection = new CdTableSelection();
+  settings: any;
+  status: string;
+  summaryDataSubscription: Subscription;
+  tableActions: CdTableAction[];
+  targets = [];
+
+  constructor(
+    private authStorageService: AuthStorageService,
+    private i18n: I18n,
+    private iscsiService: IscsiService,
+    private taskListService: TaskListService,
+    private cephReleaseNamePipe: CephReleaseNamePipe,
+    private summaryservice: SummaryService
+  ) {
+    this.permissions = this.authStorageService.getPermissions();
+  }
+
+  ngOnInit() {
+    this.columns = [
+      {
+        name: this.i18n('Target'),
+        prop: 'target_iqn',
+        flexGrow: 2,
+        cellTransformation: CellTemplate.executing
+      },
+      {
+        name: this.i18n('Portals'),
+        prop: 'cdPortals',
+        flexGrow: 2
+      },
+      {
+        name: this.i18n('Images'),
+        prop: 'cdImages',
+        flexGrow: 2
+      }
+    ];
+
+    this.iscsiService.status().subscribe((result: any) => {
+      this.available = result.available;
+
+      if (result.available) {
+        this.taskListService.init(
+          () => this.iscsiService.listTargets(),
+          (resp) => this.prepareResponse(resp),
+          (targets) => (this.targets = targets),
+          () => this.onFetchError(),
+          () => false,
+          () => false,
+          undefined
+        );
+
+        this.iscsiService.settings().subscribe((settings: any) => {
+          this.settings = settings;
+        });
+      } else {
+        const summary = this.summaryservice.getCurrentSummary();
+        const releaseName = this.cephReleaseNamePipe.transform(summary.version);
+        this.docsUrl = `http://docs.ceph.com/docs/${releaseName}/rbd/iscsi-targets/`;
+        this.status = result.message;
+      }
+    });
+  }
+
+  ngOnDestroy() {
+    if (this.summaryDataSubscription) {
+      this.summaryDataSubscription.unsubscribe();
+    }
+  }
+
+  prepareResponse(resp: any): any[] {
+    resp.forEach((element) => {
+      element.cdPortals = element.portals.map((portal) => `${portal.host}:${portal.ip}`);
+      element.cdImages = element.disks.map((disk) => `${disk.pool}/${disk.image}`);
+    });
+
+    return resp;
+  }
+
+  onFetchError() {
+    this.table.reset(); // Disable loading indicator.
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+}
index b2b42a65254a42871592841d58f0bb8e31e8bf8b..7cbfcbf6f37f311ba16196b1b7d6e2e0ba0f3dca 100644 (file)
@@ -1,3 +1,5 @@
+<cd-iscsi-tabs></cd-iscsi-tabs>
+
 <legend i18n>Daemons</legend>
 <cd-table [data]="daemons"
           (fetchData)="refresh()"
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts
new file mode 100644 (file)
index 0000000..e7a15b8
--- /dev/null
@@ -0,0 +1,73 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '../../../testing/unit-test-helper';
+import { UserFormModel } from '../../core/auth/user-form/user-form.model';
+import { UserService } from './user.service';
+
+describe('UserService', () => {
+  let service: UserService;
+  let httpTesting: HttpTestingController;
+
+  configureTestBed({
+    providers: [UserService],
+    imports: [HttpClientTestingModule]
+  });
+
+  beforeEach(() => {
+    service = TestBed.get(UserService);
+    httpTesting = TestBed.get(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpTesting.verify();
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should call create', () => {
+    const user = new UserFormModel();
+    user.username = 'user0';
+    user.password = 'pass0';
+    user.name = 'User 0';
+    user.email = 'user0@email.com';
+    user.roles = ['administrator'];
+    service.create(user).subscribe();
+    const req = httpTesting.expectOne('api/user');
+    expect(req.request.method).toBe('POST');
+    expect(req.request.body).toEqual(user);
+  });
+
+  it('should call delete', () => {
+    service.delete('user0').subscribe();
+    const req = httpTesting.expectOne('api/user/user0');
+    expect(req.request.method).toBe('DELETE');
+  });
+
+  it('should call update', () => {
+    const user = new UserFormModel();
+    user.username = 'user0';
+    user.password = 'pass0';
+    user.name = 'User 0';
+    user.email = 'user0@email.com';
+    user.roles = ['administrator'];
+    service.update(user).subscribe();
+    const req = httpTesting.expectOne('api/user/user0');
+    expect(req.request.body).toEqual(user);
+    expect(req.request.method).toBe('PUT');
+  });
+
+  it('should call get', () => {
+    service.get('user0').subscribe();
+    const req = httpTesting.expectOne('api/user/user0');
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should call list', () => {
+    service.list().subscribe();
+    const req = httpTesting.expectOne('api/user');
+    expect(req.request.method).toBe('GET');
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts
new file mode 100644 (file)
index 0000000..8bdb1a6
--- /dev/null
@@ -0,0 +1,86 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { ApiModule } from './api.module';
+
+@Injectable({
+  providedIn: ApiModule
+})
+export class IscsiService {
+  constructor(private http: HttpClient) {}
+
+  targetAdvancedSettings = {
+    cmdsn_depth: {
+      help: ''
+    },
+    dataout_timeout: {
+      help: ''
+    },
+    first_burst_length: {
+      help: ''
+    },
+    immediate_data: {
+      help: ''
+    },
+    initial_r2t: {
+      help: ''
+    },
+    max_burst_length: {
+      help: ''
+    },
+    max_outstanding_r2t: {
+      help: ''
+    },
+    max_recv_data_segment_length: {
+      help: ''
+    },
+    max_xmit_data_segment_length: {
+      help: ''
+    },
+    nopin_response_timeout: {
+      help: ''
+    },
+    nopin_timeout: {
+      help: ''
+    }
+  };
+
+  imageAdvancedSettings = {
+    hw_max_sectors: {
+      help: ''
+    },
+    max_data_area_mb: {
+      help: ''
+    },
+    osd_op_timeout: {
+      help: ''
+    },
+    qfull_timeout: {
+      help: ''
+    }
+  };
+
+  listTargets() {
+    return this.http.get(`api/iscsi/target`);
+  }
+
+  status() {
+    return this.http.get(`ui-api/iscsi/status`);
+  }
+
+  settings() {
+    return this.http.get(`ui-api/iscsi/settings`);
+  }
+
+  portals() {
+    return this.http.get(`ui-api/iscsi/portals`);
+  }
+
+  createTarget(target) {
+    return this.http.post(`api/iscsi/target`, target, { observe: 'response' });
+  }
+
+  deleteTarget(targetIqn) {
+    return this.http.delete(`api/iscsi/target/${targetIqn}`, { observe: 'response' });
+  }
+}