]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: orchestrator integration initial works
authorKiefer Chang <kiefer.chang@suse.com>
Fri, 19 Jul 2019 10:09:49 +0000 (18:09 +0800)
committerKiefer Chang <kiefer.chang@suse.com>
Fri, 30 Aug 2019 05:36:17 +0000 (13:36 +0800)
- Display hosts, inventory, and services from orchestrator
- Allow adding/removing hosts

Fixes: https://tracker.ceph.com/issues/40337
Fixes: https://tracker.ceph.com/issues/40336
Fixes: https://tracker.ceph.com/issues/38233
Signed-off-by: Kiefer Chang <kiefer.chang@suse.com>
36 files changed:
src/pybind/mgr/dashboard/controllers/health.py
src/pybind/mgr/dashboard/controllers/host.py
src/pybind/mgr/dashboard/controllers/orchestrator.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-details/host-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/services/exception.py
src/pybind/mgr/dashboard/services/ganesha.py
src/pybind/mgr/dashboard/services/iscsi_config.py
src/pybind/mgr/dashboard/services/orchestrator.py
src/pybind/mgr/dashboard/tests/test_iscsi.py

index ecb771cd01f3628aa70b9d6a6d5d3779f4ab06cf..2795d89ce50f404b71a15ea4eec34f8417de2512 100644 (file)
@@ -12,6 +12,7 @@ from ..services.ceph_service import CephService
 from ..services.iscsi_cli import IscsiGatewaysConfig
 from ..services.iscsi_client import IscsiClient
 from ..tools import partial_dict
+from .host import get_hosts
 
 
 class HealthData(object):
@@ -117,7 +118,7 @@ class HealthData(object):
         return fs_map
 
     def host_count(self):
-        return len(mgr.list_servers())
+        return len(get_hosts())
 
     def iscsi_daemons(self):
         up_counter = 0
index e8518a14c9218a69e669710ad3573d32fd8c0e38..26bff7bc8d4b7aa2344b66fe5d2cdcb86d5171c2 100644 (file)
 # -*- coding: utf-8 -*-
 from __future__ import absolute_import
+import copy
 
-from . import ApiController, RESTController
+from mgr_util import merge_dicts
+from . import ApiController, RESTController, Task
 from .. import mgr
+from ..exceptions import DashboardException
 from ..security import Scope
+from ..services.orchestrator import OrchClient
+from ..services.exception import handle_orchestrator_error
+
+
+def host_task(name, metadata, wait_for=10.0):
+    return Task("host/{}".format(name), metadata, wait_for)
+
+
+def merge_hosts_by_hostname(ceph_hosts, orch_hosts):
+    """Merge Ceph hosts with orchestrator hosts by hostnames.
+
+    :param mgr_hosts: hosts returned from mgr
+    :type mgr_hosts: list of dict
+    :param orch_hosts: hosts returned from ochestrator
+    :type orch_hosts: list of InventoryNode
+    :return list of dict
+    """
+    _ceph_hosts = copy.deepcopy(ceph_hosts)
+    orch_hostnames = {host.name for host in orch_hosts}
+
+    # hosts in both Ceph and Orchestrator
+    for ceph_host in _ceph_hosts:
+        if ceph_host['hostname'] in orch_hostnames:
+            ceph_host['sources']['orchestrator'] = True
+            orch_hostnames.remove(ceph_host['hostname'])
+
+    # Hosts only in Orchestrator
+    orch_sources = {'ceph': False, 'orchestrator': True}
+    orch_hosts = [dict(hostname=hostname, ceph_version='', services=[], sources=orch_sources)
+                  for hostname in orch_hostnames]
+    _ceph_hosts.extend(orch_hosts)
+    return _ceph_hosts
+
+
+def get_hosts(from_ceph=True, from_orchestrator=True):
+    """get hosts from various sources"""
+    ceph_hosts = []
+    if from_ceph:
+        ceph_hosts = [merge_dicts(server, {'sources': {'ceph': True, 'orchestrator': False}})
+                      for server in mgr.list_servers()]
+    if from_orchestrator:
+        orch = OrchClient.instance()
+        if orch.available():
+            return merge_hosts_by_hostname(ceph_hosts, orch.hosts.list())
+    return ceph_hosts
 
 
 @ApiController('/host', Scope.HOSTS)
 class Host(RESTController):
-    def list(self):
-        return mgr.list_servers()
+
+    def list(self, sources=None):
+        if sources is None:
+            return get_hosts()
+        _sources = sources.split(',')
+        from_ceph = 'ceph' in _sources
+        from_orchestrator = 'orchestrator' in _sources
+        return get_hosts(from_ceph, from_orchestrator)
+
+    @host_task('add', {'hostname': '{hostname}'})
+    @handle_orchestrator_error('host')
+    def create(self, hostname):
+        orch_client = OrchClient.instance()
+        self._check_orchestrator_host_op(orch_client, hostname, True)
+        orch_client.hosts.add(hostname)
+
+    @host_task('remove', {'hostname': '{hostname}'})
+    @handle_orchestrator_error('host')
+    def delete(self, hostname):
+        orch_client = OrchClient.instance()
+        self._check_orchestrator_host_op(orch_client, hostname, False)
+        orch_client.hosts.remove(hostname)
+
+    def _check_orchestrator_host_op(self, orch_client, hostname, add_host=True):
+        """Check if we can adding or removing a host with orchestrator
+
+        :param orch_client: Orchestrator client
+        :param add: True for adding host operation, False for removing host
+        :raise DashboardException
+        """
+        if not orch_client.available():
+            raise DashboardException(code='orchestrator_status_unavailable',
+                                     msg='Orchestrator is unavailable',
+                                     component='orchestrator')
+        host = orch_client.hosts.get(hostname)
+        if add_host and host:
+            raise DashboardException(
+                code='orchestrator_add_existed_host',
+                msg='{} is already in orchestrator'.format(hostname),
+                component='orchestrator')
+        if not add_host and not host:
+            raise DashboardException(
+                code='orchestrator_remove_nonexistent_host',
+                msg='Remove a non-existent host {} from orchestrator'.format(
+                    hostname),
+                component='orchestrator')
diff --git a/src/pybind/mgr/dashboard/controllers/orchestrator.py b/src/pybind/mgr/dashboard/controllers/orchestrator.py
new file mode 100644 (file)
index 0000000..1f0839c
--- /dev/null
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from . import ApiController, Endpoint, ReadPermission
+from . import RESTController, Task
+from ..security import Scope
+from ..services.orchestrator import OrchClient
+
+
+def orchestrator_task(name, metadata, wait_for=2.0):
+    return Task("orchestrator/{}".format(name), metadata, wait_for)
+
+
+@ApiController('/orchestrator')
+class Orchestrator(RESTController):
+
+    @Endpoint()
+    @ReadPermission
+    def status(self):
+        return OrchClient.instance().status()
+
+
+@ApiController('/orchestrator/inventory', Scope.HOSTS)
+class OrchestratorInventory(RESTController):
+
+    def list(self, hostname=None):
+        orch = OrchClient.instance()
+        result = []
+
+        if orch.available():
+            hosts = [hostname] if hostname else None
+            inventory_nodes = orch.inventory.list(hosts)
+            result = [node.to_json() for node in inventory_nodes]
+        return result
+
+
+@ApiController('/orchestrator/service', Scope.HOSTS)
+class OrchestratorService(RESTController):
+    def list(self, service_type=None, service_id=None, hostname=None):
+        orch = OrchClient.instance()
+        services = []
+
+        if orch.available():
+            services = [service.to_json() for service in orch.services.list(
+                service_type, service_id, hostname)]
+        return services
index f8b0ec94a455e301b606b3911ee0108df80a1e61..875ca13f5fbc3fbebe95f237b4a096fcbe167d5c 100644 (file)
@@ -7,7 +7,9 @@ import { CephfsListComponent } from './ceph/cephfs/cephfs-list/cephfs-list.compo
 import { ConfigurationFormComponent } from './ceph/cluster/configuration/configuration-form/configuration-form.component';
 import { ConfigurationComponent } from './ceph/cluster/configuration/configuration.component';
 import { CrushmapComponent } from './ceph/cluster/crushmap/crushmap.component';
+import { HostFormComponent } from './ceph/cluster/hosts/host-form/host-form.component';
 import { HostsComponent } from './ceph/cluster/hosts/hosts.component';
+import { InventoryComponent } from './ceph/cluster/inventory/inventory.component';
 import { LogsComponent } from './ceph/cluster/logs/logs.component';
 import { MgrModuleFormComponent } from './ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component';
 import { MgrModuleListComponent } from './ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component';
@@ -16,6 +18,7 @@ import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component
 import { AlertListComponent } from './ceph/cluster/prometheus/alert-list/alert-list.component';
 import { SilenceFormComponent } from './ceph/cluster/prometheus/silence-form/silence-form.component';
 import { SilenceListComponent } from './ceph/cluster/prometheus/silence-list/silence-list.component';
+import { ServicesComponent } from './ceph/cluster/services/services.component';
 import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
 import { Nfs501Component } from './ceph/nfs/nfs-501/nfs-501.component';
 import { NfsFormComponent } from './ceph/nfs/nfs-form/nfs-form.component';
@@ -70,9 +73,16 @@ const routes: Routes = [
   // Cluster
   {
     path: 'hosts',
-    component: HostsComponent,
     canActivate: [AuthGuardService],
-    data: { breadcrumbs: 'Cluster/Hosts' }
+    data: { breadcrumbs: 'Cluster/Hosts' },
+    children: [
+      { path: '', component: HostsComponent },
+      {
+        path: URLVerbs.ADD,
+        component: HostFormComponent,
+        data: { breadcrumbs: ActionLabels.ADD }
+      }
+    ]
   },
   {
     path: 'monitor',
@@ -80,6 +90,18 @@ const routes: Routes = [
     canActivate: [AuthGuardService],
     data: { breadcrumbs: 'Cluster/Monitors' }
   },
+  {
+    path: 'services',
+    component: ServicesComponent,
+    canActivate: [AuthGuardService],
+    data: { breadcrumbs: 'Cluster/Services' }
+  },
+  {
+    path: 'inventory',
+    component: InventoryComponent,
+    canActivate: [AuthGuardService],
+    data: { breadcrumbs: 'Cluster/Inventory' }
+  },
   {
     path: 'osd',
     canActivate: [AuthGuardService],
index f38476e1d1c635d376af6733e60bec02c23f64e5..ee7719eaad65b171a7c3fa5409ad7cd6b28c9dbc 100644 (file)
@@ -21,7 +21,9 @@ import { ConfigurationFormComponent } from './configuration/configuration-form/c
 import { ConfigurationComponent } from './configuration/configuration.component';
 import { CrushmapComponent } from './crushmap/crushmap.component';
 import { HostDetailsComponent } from './hosts/host-details/host-details.component';
+import { HostFormComponent } from './hosts/host-form/host-form.component';
 import { HostsComponent } from './hosts/hosts.component';
+import { InventoryComponent } from './inventory/inventory.component';
 import { LogsComponent } from './logs/logs.component';
 import { MgrModulesModule } from './mgr-modules/mgr-modules.module';
 import { MonitorComponent } from './monitor/monitor.component';
@@ -38,6 +40,7 @@ import { PrometheusTabsComponent } from './prometheus/prometheus-tabs/prometheus
 import { SilenceFormComponent } from './prometheus/silence-form/silence-form.component';
 import { SilenceListComponent } from './prometheus/silence-list/silence-list.component';
 import { SilenceMatcherModalComponent } from './prometheus/silence-matcher-modal/silence-matcher-modal.component';
+import { ServicesComponent } from './services/services.component';
 
 @NgModule({
   entryComponents: [
@@ -92,7 +95,10 @@ import { SilenceMatcherModalComponent } from './prometheus/silence-matcher-modal
     SilenceFormComponent,
     SilenceListComponent,
     PrometheusTabsComponent,
-    SilenceMatcherModalComponent
+    SilenceMatcherModalComponent,
+    ServicesComponent,
+    InventoryComponent,
+    HostFormComponent
   ]
 })
 export class ClusterModule {}
index 005a87497a658ee31bcc4e7f7cf9858ed5d8c013..9cd20068f8bde2304f55ec028585282a9fa6b2eb 100644 (file)
@@ -1,6 +1,21 @@
-<tabset *ngIf="selection.hasSingleSelection && grafanaPermission.read">
+<tabset *ngIf="selection.hasSingleSelection">
   <tab i18n-heading
-       heading="Performance Details">
+       heading="Inventory"
+       *ngIf="hostsPermission.read">
+    <cd-inventory
+      [hostname]="host['hostname']">
+    </cd-inventory>
+  </tab>
+  <tab i18n-heading
+       heading="Services"
+       *ngIf="hostsPermission.read">
+    <cd-services
+      [hostname]="host['hostname']">
+    </cd-services>
+  </tab>
+  <tab i18n-heading
+       heading="Performance Details"
+       *ngIf="grafanaPermission.read">
     <cd-grafana [grafanaPath]="'host-details?var-ceph_hosts=' + host['hostname']"
                 uid="rtOg0AiWz"
                 grafanaStyle="three">
index 0a37e609704ff063e39012fc9a4363474aed8ab2..99b396c55d38bc42c2e1964b5f3ab92d71b6c90f 100644 (file)
@@ -7,6 +7,8 @@ import { TabsModule } from 'ngx-bootstrap/tabs';
 import { configureTestBed } from '../../../../../testing/unit-test-helper';
 import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
 import { SharedModule } from '../../../../shared/shared.module';
+import { InventoryComponent } from '../../inventory/inventory.component';
+import { ServicesComponent } from '../../services/services.component';
 import { HostDetailsComponent } from './host-details.component';
 
 describe('HostDetailsComponent', () => {
@@ -20,7 +22,7 @@ describe('HostDetailsComponent', () => {
       BsDropdownModule.forRoot(),
       SharedModule
     ],
-    declarations: [HostDetailsComponent]
+    declarations: [HostDetailsComponent, InventoryComponent, ServicesComponent]
   });
 
   beforeEach(() => {
index dfd3c16f31d54c743a173adc042a5c200e851a6d..1c4bf2c0b6d34d3dd293d826e2713fe2eb6a915d 100644 (file)
@@ -11,12 +11,15 @@ import { AuthStorageService } from '../../../../shared/services/auth-storage.ser
 })
 export class HostDetailsComponent implements OnChanges {
   grafanaPermission: Permission;
+  hostsPermission: Permission;
+
   @Input()
   selection: CdTableSelection;
   host: any;
 
   constructor(private authStorageService: AuthStorageService) {
     this.grafanaPermission = this.authStorageService.getPermissions().grafana;
+    this.hostsPermission = this.authStorageService.getPermissions().hosts;
   }
 
   ngOnChanges() {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html
new file mode 100644 (file)
index 0000000..0371785
--- /dev/null
@@ -0,0 +1,51 @@
+<cd-loading-panel *ngIf="loading"
+                  i18n>Loading...</cd-loading-panel>
+
+<div class="col-sm-12 col-lg-6">
+  <form name="hostForm"
+        *ngIf="!loading"
+        #formDir="ngForm"
+        [formGroup]="hostForm"
+        novalidate>
+    <div class="card">
+      <div i18n="form title|Example: Create Pool@@formTitle"
+           class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+
+      <div class="card-body">
+
+        <!-- Hostname -->
+        <div class="form-group row">
+          <label class="col-form-label col-sm-3"
+                 for="hostname">
+            <ng-container i18n>Hostname</ng-container>
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <input class="form-control"
+                   type="text"
+                   placeholder="mon-123"
+                   id="hostname"
+                   name="hostname"
+                   formControlName="hostname"
+                   autofocus>
+            <span class="invalid-feedback"
+                  *ngIf="hostForm.showError('hostname', formDir, 'required')"
+                  i18n>This field is required.</span>
+            <span class="invalid-feedback"
+                  *ngIf="hostForm.showError('hostname', formDir, 'uniqueName')"
+                  i18n>The chosen hostname is already in use.</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="card-footer">
+        <div class="button-group text-right">
+          <cd-submit-button [form]="formDir"
+                            i18n="form action button|Example: Create Pool@@formActionButton"
+                            (submitAction)="submit()">{{ action | titlecase }} {{ resource | upperFirst }}</cd-submit-button>
+          <cd-back-button></cd-back-button>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>
\ No newline at end of file
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts
new file mode 100644 (file)
index 0000000..4ed2e38
--- /dev/null
@@ -0,0 +1,37 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../../shared/shared.module';
+import { HostFormComponent } from './host-form.component';
+
+describe('HostFormComponent', () => {
+  let component: HostFormComponent;
+  let fixture: ComponentFixture<HostFormComponent>;
+
+  configureTestBed({
+    imports: [
+      SharedModule,
+      HttpClientTestingModule,
+      RouterTestingModule,
+      ReactiveFormsModule,
+      ToastrModule.forRoot()
+    ],
+    providers: [i18nProviders],
+    declarations: [HostFormComponent]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(HostFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts
new file mode 100644 (file)
index 0000000..5947351
--- /dev/null
@@ -0,0 +1,77 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+import { Router } from '@angular/router';
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import { HostService } from '../../../../shared/api/host.service';
+import { ActionLabelsI18n, URLVerbs } from '../../../../shared/constants/app.constants';
+import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../../shared/forms/cd-validators';
+import { FinishedTask } from '../../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../../shared/services/task-wrapper.service';
+
+@Component({
+  selector: 'cd-host-form',
+  templateUrl: './host-form.component.html',
+  styleUrls: ['./host-form.component.scss']
+})
+export class HostFormComponent implements OnInit {
+  hostForm: CdFormGroup;
+  action: string;
+  resource: string;
+  loading = true;
+  hostnames: string[];
+
+  constructor(
+    private router: Router,
+    private i18n: I18n,
+    private actionLabels: ActionLabelsI18n,
+    private hostService: HostService,
+    private taskWrapper: TaskWrapperService
+  ) {
+    this.resource = this.i18n('host');
+    this.action = this.actionLabels.ADD;
+    this.createForm();
+  }
+
+  ngOnInit() {
+    this.hostService.list().subscribe((resp: any[]) => {
+      this.hostnames = resp.map((host) => {
+        return host['hostname'];
+      });
+      this.loading = false;
+    });
+  }
+
+  private createForm() {
+    this.hostForm = new CdFormGroup({
+      hostname: new FormControl('', {
+        validators: [
+          Validators.required,
+          CdValidators.custom('uniqueName', (hostname: string) => {
+            return this.hostnames && this.hostnames.indexOf(hostname) !== -1;
+          })
+        ]
+      })
+    });
+  }
+
+  submit() {
+    const hostname = this.hostForm.get('hostname').value;
+    this.taskWrapper
+      .wrapTaskAroundCall({
+        task: new FinishedTask('host/' + URLVerbs.ADD, {
+          hostname: hostname
+        }),
+        call: this.hostService.add(hostname)
+      })
+      .subscribe(
+        undefined,
+        () => {
+          this.hostForm.setErrors({ cdSubmitButton: true });
+        },
+        () => {
+          this.router.navigate(['/hosts']);
+        }
+      );
+  }
+}
index 799af3e471e314d845b3390f3c201b5be12d751b..1cd4ff468252e2e6a21e653ca5f1257bd2bfa282 100644 (file)
@@ -7,6 +7,14 @@
               (fetchData)="getHosts($event)"
               selectionType="single"
               (updateSelection)="updateSelection($event)">
+      <div class="table-actions btn-toolbar">
+        <cd-table-actions [permission]="permissions.hosts"
+                          [selection]="selection"
+                          class="btn-group"
+                          id="host-actions"
+                          [tableActions]="tableActions">
+        </cd-table-actions>
+      </div>
       <ng-template #servicesTpl let-value="value">
         <span *ngFor="let service of value; last as isLast">
           <a class="service-link"
index d1264d52e8ad41c4ba643308fcee5254f9fabd68..73bddde6e53e11e18ae5bfa98b73ba5793fd0ea1 100644 (file)
@@ -5,11 +5,15 @@ import { RouterTestingModule } from '@angular/router/testing';
 import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
 import { TabsModule } from 'ngx-bootstrap/tabs';
 
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
 import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
 import { HostService } from '../../../shared/api/host.service';
 import { Permissions } from '../../../shared/models/permissions';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { SharedModule } from '../../../shared/shared.module';
+import { InventoryComponent } from '../inventory/inventory.component';
+import { ServicesComponent } from '../services/services.component';
 import { HostDetailsComponent } from './host-details/host-details.component';
 import { HostsComponent } from './hosts.component';
 
@@ -30,10 +34,11 @@ describe('HostsComponent', () => {
       HttpClientTestingModule,
       TabsModule.forRoot(),
       BsDropdownModule.forRoot(),
-      RouterTestingModule
+      RouterTestingModule,
+      ToastrModule.forRoot()
     ],
     providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }, i18nProviders],
-    declarations: [HostsComponent, HostDetailsComponent]
+    declarations: [HostsComponent, HostDetailsComponent, InventoryComponent, ServicesComponent]
   });
 
   beforeEach(() => {
@@ -70,7 +75,7 @@ describe('HostsComponent', () => {
       }
     ];
 
-    hostListSpy.and.returnValue(Promise.resolve(payload));
+    hostListSpy.and.callFake(() => of(payload));
 
     fixture.whenStable().then(() => {
       fixture.detectChanges();
index fb663a5ca53ac81a8a67b10115edc48eeef9ae6d..1767c7be76efda86f5d5a89905ac65720acc3483 100644 (file)
@@ -1,27 +1,42 @@
 import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
 
 import { I18n } from '@ngx-translate/i18n-polyfill';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
 
 import { HostService } from '../../../shared/api/host.service';
+import { OrchestratorService } from '../../../shared/api/orchestrator.service';
+import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
+import { Icons } from '../../../shared/enum/icons.enum';
+import { CdTableAction } from '../../../shared/models/cd-table-action';
 import { CdTableColumn } from '../../../shared/models/cd-table-column';
 import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context';
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { FinishedTask } from '../../../shared/models/finished-task';
 import { Permissions } from '../../../shared/models/permissions';
 import { CephShortVersionPipe } from '../../../shared/pipes/ceph-short-version.pipe';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { URLBuilderService } from '../../../shared/services/url-builder.service';
+
+const BASE_URL = 'hosts';
 
 @Component({
   selector: 'cd-hosts',
   templateUrl: './hosts.component.html',
-  styleUrls: ['./hosts.component.scss']
+  styleUrls: ['./hosts.component.scss'],
+  providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
 })
 export class HostsComponent implements OnInit {
   permissions: Permissions;
   columns: Array<CdTableColumn> = [];
   hosts: Array<object> = [];
   isLoadingHosts = false;
+  orchestratorAvailable = false;
   cdParams = { fromLink: '/hosts' };
+  tableActions: CdTableAction[];
   selection = new CdTableSelection();
+  modalRef: BsModalRef;
 
   @ViewChild('servicesTpl', { static: true })
   public servicesTpl: TemplateRef<any>;
@@ -30,9 +45,32 @@ export class HostsComponent implements OnInit {
     private authStorageService: AuthStorageService,
     private hostService: HostService,
     private cephShortVersionPipe: CephShortVersionPipe,
-    private i18n: I18n
+    private i18n: I18n,
+    private urlBuilder: URLBuilderService,
+    private actionLabels: ActionLabelsI18n,
+    private modalService: BsModalService,
+    private taskWrapper: TaskWrapperService,
+    private orchService: OrchestratorService
   ) {
     this.permissions = this.authStorageService.getPermissions();
+    this.tableActions = [
+      {
+        name: this.actionLabels.ADD,
+        permission: 'create',
+        icon: Icons.add,
+        routerLink: () => this.urlBuilder.getAdd(),
+        disable: () => !this.orchestratorAvailable,
+        disableDesc: () => this.getDisableDesc()
+      },
+      {
+        name: this.actionLabels.REMOVE,
+        permission: 'delete',
+        icon: Icons.destroy,
+        click: () => this.deleteHostModal(),
+        disable: () => !this.orchestratorAvailable || !this.selection.hasSelection,
+        disableDesc: () => this.getDisableDesc()
+      }
+    ];
   }
 
   ngOnInit() {
@@ -55,12 +93,31 @@ export class HostsComponent implements OnInit {
         pipe: this.cephShortVersionPipe
       }
     ];
+
+    this.orchService.status().subscribe((data: { available: boolean }) => {
+      this.orchestratorAvailable = data.available;
+    });
   }
 
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
 
+  deleteHostModal() {
+    const hostname = this.selection.first().hostname;
+    this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+      initialState: {
+        itemDescription: 'Host',
+        actionDescription: 'remove',
+        submitActionObservable: () =>
+          this.taskWrapper.wrapTaskAroundCall({
+            task: new FinishedTask('host/remove', { hostname: hostname }),
+            call: this.hostService.remove(hostname)
+          })
+      }
+    });
+  }
+
   getHosts(context: CdTableFetchDataContext) {
     if (this.isLoadingHosts) {
       return;
@@ -75,9 +132,8 @@ export class HostsComponent implements OnInit {
       'tcmu-runner': 'iscsi'
     };
     this.isLoadingHosts = true;
-    this.hostService
-      .list()
-      .then((resp) => {
+    this.hostService.list().subscribe(
+      (resp: any[]) => {
         resp.map((host) => {
           host.services.map((service) => {
             service.cdLink = `/perf_counters/${service.type}/${encodeURIComponent(service.id)}`;
@@ -89,10 +145,17 @@ export class HostsComponent implements OnInit {
         });
         this.hosts = resp;
         this.isLoadingHosts = false;
-      })
-      .catch(() => {
+      },
+      () => {
         this.isLoadingHosts = false;
         context.error();
-      });
+      }
+    );
+  }
+
+  getDisableDesc() {
+    if (!this.orchestratorAvailable) {
+      return this.i18n('Host operation is disabled because orchestrator is unavailable');
+    }
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html
new file mode 100644 (file)
index 0000000..3d00195
--- /dev/null
@@ -0,0 +1,21 @@
+<cd-info-panel *ngIf="!orchestratorExist && !checkingOrchestrator"
+               i18n>Please consult the
+  <a href="{{ docsUrl }}"
+     target="_blank">documentation</a> on how to
+  configure and enable the orchestrator functionality.</cd-info-panel>
+
+<ng-container *ngIf="orchestratorExist">
+  <legend i18n>Devices</legend>
+  <div class="row">
+    <div class="col-md-12">
+      <cd-table [data]="devices"
+                [columns]="columns"
+                identifier="uid"
+                forceIdentifier="true"
+                columnMode="flex"
+                (fetchData)="getInventory($event)"
+                selectionType="single">
+      </cd-table>
+    </div>
+  </div>
+</ng-container>
\ No newline at end of file
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts
new file mode 100644 (file)
index 0000000..9336ff8
--- /dev/null
@@ -0,0 +1,28 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../shared/shared.module';
+import { InventoryComponent } from './inventory.component';
+
+describe('InventoryComponent', () => {
+  let component: InventoryComponent;
+  let fixture: ComponentFixture<InventoryComponent>;
+
+  configureTestBed({
+    imports: [SharedModule, HttpClientTestingModule, RouterTestingModule],
+    providers: [i18nProviders],
+    declarations: [InventoryComponent]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(InventoryComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts
new file mode 100644 (file)
index 0000000..b598b08
--- /dev/null
@@ -0,0 +1,136 @@
+import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
+import { I18n } from '@ngx-translate/i18n-polyfill';
+
+import { OrchestratorService } from '../../../shared/api/orchestrator.service';
+import { TableComponent } from '../../../shared/datatable/table/table.component';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context';
+import { CephReleaseNamePipe } from '../../../shared/pipes/ceph-release-name.pipe';
+import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { SummaryService } from '../../../shared/services/summary.service';
+import { Device, InventoryNode } from './inventory.model';
+
+@Component({
+  selector: 'cd-inventory',
+  templateUrl: './inventory.component.html',
+  styleUrls: ['./inventory.component.scss']
+})
+export class InventoryComponent implements OnChanges, OnInit {
+  @ViewChild(TableComponent)
+  table: TableComponent;
+
+  @Input() hostname = '';
+
+  checkingOrchestrator = true;
+  orchestratorExist = false;
+  docsUrl: string;
+
+  columns: Array<CdTableColumn> = [];
+  devices: Array<Device> = [];
+  isLoadingDevices = false;
+
+  constructor(
+    private cephReleaseNamePipe: CephReleaseNamePipe,
+    private dimlessBinary: DimlessBinaryPipe,
+    private i18n: I18n,
+    private orchService: OrchestratorService,
+    private summaryService: SummaryService
+  ) {}
+
+  ngOnInit() {
+    this.columns = [
+      {
+        name: this.i18n('Device path'),
+        prop: 'id',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Type'),
+        prop: 'type',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Size'),
+        prop: 'size',
+        flexGrow: 1,
+        pipe: this.dimlessBinary
+      },
+      {
+        name: this.i18n('Rotates'),
+        prop: 'rotates',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Available'),
+        prop: 'available',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Model'),
+        prop: 'model',
+        flexGrow: 1
+      }
+    ];
+
+    if (!this.hostname) {
+      const hostColumn = {
+        name: this.i18n('Hostname'),
+        prop: 'hostname',
+        flexGrow: 1
+      };
+      this.columns.splice(0, 0, hostColumn);
+    }
+
+    // duplicated code with grafana
+    const subs = this.summaryService.subscribe((summary: any) => {
+      if (!summary) {
+        return;
+      }
+
+      const releaseName = this.cephReleaseNamePipe.transform(summary.version);
+      this.docsUrl = `http://docs.ceph.com/docs/${releaseName}/mgr/orchestrator_cli/`;
+
+      setTimeout(() => {
+        subs.unsubscribe();
+      }, 0);
+    });
+
+    this.orchService.status().subscribe((data: { available: boolean }) => {
+      this.orchestratorExist = data.available;
+      this.checkingOrchestrator = false;
+    });
+  }
+
+  ngOnChanges() {
+    if (this.orchestratorExist) {
+      this.devices = [];
+      this.table.reloadData();
+    }
+  }
+
+  getInventory(context: CdTableFetchDataContext) {
+    if (this.isLoadingDevices) {
+      return;
+    }
+    this.isLoadingDevices = true;
+    this.orchService.inventoryList(this.hostname).subscribe(
+      (data: InventoryNode[]) => {
+        const devices: Device[] = [];
+        data.forEach((node: InventoryNode) => {
+          node.devices.forEach((device: Device) => {
+            device.hostname = node.name;
+            device.uid = `${node.name}-${device.id}`;
+            devices.push(device);
+          });
+        });
+        this.devices = devices;
+        this.isLoadingDevices = false;
+      },
+      () => {
+        this.isLoadingDevices = false;
+        this.devices = [];
+        context.error();
+      }
+    );
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.model.ts
new file mode 100644 (file)
index 0000000..50b833f
--- /dev/null
@@ -0,0 +1,10 @@
+export interface Device {
+  id: string;
+  hostname: string;
+  uid: string;
+}
+
+export interface InventoryNode {
+  name: string;
+  devices: Device[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html
new file mode 100644 (file)
index 0000000..dc897b6
--- /dev/null
@@ -0,0 +1,16 @@
+<cd-info-panel *ngIf="!orchestratorExist && !checkingOrchestrator"
+               i18n>Please consult the
+  <a href="{{ docsUrl }}"
+     target="_blank">documentation</a> on how to
+  configure and enable the orchestrator functionality.</cd-info-panel>
+
+<ng-container *ngIf="orchestratorExist">
+  <cd-table [data]="services"
+            [columns]="columns"
+            identifier="uid"
+            forceIdentifier="true"
+            columnMode="flex"
+            (fetchData)="getServices($event)"
+            selectionType="single">
+  </cd-table>
+</ng-container>
\ No newline at end of file
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.spec.ts
new file mode 100644 (file)
index 0000000..4faddc3
--- /dev/null
@@ -0,0 +1,28 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../shared/shared.module';
+import { ServicesComponent } from './services.component';
+
+describe('ServicesComponent', () => {
+  let component: ServicesComponent;
+  let fixture: ComponentFixture<ServicesComponent>;
+
+  configureTestBed({
+    imports: [SharedModule, HttpClientTestingModule, RouterTestingModule],
+    providers: [i18nProviders],
+    declarations: [ServicesComponent]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(ServicesComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts
new file mode 100644 (file)
index 0000000..3e0849d
--- /dev/null
@@ -0,0 +1,147 @@
+import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
+import { I18n } from '@ngx-translate/i18n-polyfill';
+
+import { OrchestratorService } from '../../../shared/api/orchestrator.service';
+import { TableComponent } from '../../../shared/datatable/table/table.component';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context';
+import { CephReleaseNamePipe } from '../../../shared/pipes/ceph-release-name.pipe';
+import { SummaryService } from '../../../shared/services/summary.service';
+import { Service } from './services.model';
+
+@Component({
+  selector: 'cd-services',
+  templateUrl: './services.component.html',
+  styleUrls: ['./services.component.scss']
+})
+export class ServicesComponent implements OnChanges, OnInit {
+  @ViewChild(TableComponent)
+  table: TableComponent;
+
+  @Input() hostname = '';
+
+  checkingOrchestrator = true;
+  orchestratorExist = false;
+  docsUrl: string;
+
+  columns: Array<CdTableColumn> = [];
+  services: Array<object> = [];
+  isLoadingServices = false;
+
+  constructor(
+    private cephReleaseNamePipe: CephReleaseNamePipe,
+    private i18n: I18n,
+    private orchService: OrchestratorService,
+    private summaryService: SummaryService
+  ) {}
+
+  ngOnInit() {
+    this.columns = [
+      {
+        name: this.i18n('Service type'),
+        prop: 'service_type',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Service'),
+        prop: 'service',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Service instance'),
+        prop: 'service_instance',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Container id'),
+        prop: 'container_id',
+        flexGrow: 3
+      },
+      {
+        name: this.i18n('Version'),
+        prop: 'version',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Rados config location'),
+        prop: 'rados_config_location',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Service URL'),
+        prop: 'service_url',
+        flexGrow: 2
+      },
+      {
+        name: this.i18n('Status'),
+        prop: 'status',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Status Description'),
+        prop: 'status_desc',
+        flexGrow: 1
+      }
+    ];
+
+    if (!this.hostname) {
+      const hostnameColumn = {
+        name: this.i18n('Hostname'),
+        prop: 'nodename',
+        flexGrow: 2
+      };
+      this.columns.splice(0, 0, hostnameColumn);
+    }
+
+    // duplicated code with grafana
+    const subs = this.summaryService.subscribe((summary: any) => {
+      if (!summary) {
+        return;
+      }
+
+      const releaseName = this.cephReleaseNamePipe.transform(summary.version);
+      this.docsUrl = `http://docs.ceph.com/docs/${releaseName}/mgr/orchestrator_cli/`;
+
+      setTimeout(() => {
+        subs.unsubscribe();
+      }, 0);
+    });
+
+    this.orchService.status().subscribe((data: { available: boolean }) => {
+      this.orchestratorExist = data.available;
+      this.checkingOrchestrator = false;
+    });
+  }
+
+  ngOnChanges() {
+    if (this.orchestratorExist) {
+      this.services = [];
+      this.table.reloadData();
+    }
+  }
+
+  getServices(context: CdTableFetchDataContext) {
+    if (this.isLoadingServices) {
+      return;
+    }
+    this.isLoadingServices = true;
+    this.orchService.serviceList(this.hostname).subscribe(
+      (data: Service[]) => {
+        const services: Service[] = [];
+        data.forEach((service: Service) => {
+          service.uid = `${service.nodename}-${service.service_type}-${service.service}-${
+            service.service_instance
+          }`;
+          services.push(service);
+        });
+        this.services = services;
+        this.isLoadingServices = false;
+      },
+      () => {
+        this.isLoadingServices = false;
+        this.services = [];
+        context.error();
+      }
+    );
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.model.ts
new file mode 100644 (file)
index 0000000..72b8b75
--- /dev/null
@@ -0,0 +1,7 @@
+export interface Service {
+  uid: string;
+  nodename: string;
+  service_type: string;
+  service: string;
+  service_instance: string;
+}
index 42db3ecb8d2b8b605fab183031678d80c2333fa1..1e7ef31b58b35c24d0025864143f36bf13639c6b 100644 (file)
            class="dropdown-item"
            routerLink="/hosts">Hosts</a>
       </li>
+      <li routerLinkActive="active"
+          class="tc_submenuitem tc_submenuitem_cluster_inventory"
+          *ngIf="permissions.hosts.read">
+        <a i18n
+           class="dropdown-item"
+           routerLink="/inventory">Inventory</a>
+      </li>
       <li routerLinkActive="active"
           class="tc_submenuitem tc_submenuitem_cluster_monitor"
           *ngIf="permissions.monitor.read">
            class="dropdown-item"
            routerLink="/monitor/">Monitors</a>
       </li>
+      <li routerLinkActive="active"
+          class="tc_submenuitem tc_submenuitem_cluster_services"
+          *ngIf="permissions.hosts.read">
+        <a i18n
+           class="dropdown-item"
+           routerLink="/services/">Services</a>
+      </li>
       <li routerLinkActive="active"
           class="tc_submenuitem tc_submenuitem_hosts"
           *ngIf="permissions.osd.read">
index a9354ce54f7b022d7a0f738bc0b7d4bb853fa203..8be0befc1d6956b4515694b2465da5334296d180 100644 (file)
@@ -28,7 +28,7 @@ describe('HostService', () => {
 
   it('should call list', fakeAsync(() => {
     let result;
-    service.list().then((resp) => (result = resp));
+    service.list().subscribe((resp) => (result = resp));
     const req = httpTesting.expectOne('api/host');
     expect(req.request.method).toBe('GET');
     req.flush(['foo', 'bar']);
index e0338609b69f67e17386d9874baaee3d0d07b14a..453de9587e8d304a9c518803fe2ad3c4f4e0cc2e 100644 (file)
@@ -7,14 +7,19 @@ import { ApiModule } from './api.module';
   providedIn: ApiModule
 })
 export class HostService {
+  baseURL = 'api/host';
+
   constructor(private http: HttpClient) {}
 
   list() {
-    return this.http
-      .get('api/host')
-      .toPromise()
-      .then((resp: any) => {
-        return resp;
-      });
+    return this.http.get(this.baseURL);
+  }
+
+  add(hostname) {
+    return this.http.post(this.baseURL, { hostname: hostname }, { observe: 'response' });
+  }
+
+  remove(hostname) {
+    return this.http.delete(`${this.baseURL}/${hostname}`, { observe: 'response' });
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts
new file mode 100644 (file)
index 0000000..9932022
--- /dev/null
@@ -0,0 +1,19 @@
+import { TestBed } from '@angular/core/testing';
+
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { configureTestBed, i18nProviders } from '../../../testing/unit-test-helper';
+import { OrchestratorService } from './orchestrator.service';
+
+describe('OrchestratorService', () => {
+  beforeEach(() => TestBed.configureTestingModule({}));
+
+  configureTestBed({
+    providers: [OrchestratorService, i18nProviders],
+    imports: [HttpClientTestingModule]
+  });
+
+  it('should be created', () => {
+    const service: OrchestratorService = TestBed.get(OrchestratorService);
+    expect(service).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts
new file mode 100644 (file)
index 0000000..7123e72
--- /dev/null
@@ -0,0 +1,28 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { ApiModule } from './api.module';
+
+@Injectable({
+  providedIn: ApiModule
+})
+export class OrchestratorService {
+  statusURL = 'api/orchestrator/status';
+  inventoryURL = 'api/orchestrator/inventory';
+  serviceURL = 'api/orchestrator/service';
+
+  constructor(private http: HttpClient) {}
+
+  status() {
+    return this.http.get(this.statusURL);
+  }
+
+  inventoryList(hostname: string) {
+    const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {};
+    return this.http.get(this.inventoryURL, options);
+  }
+
+  serviceList(hostname: string) {
+    const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {};
+    return this.http.get(this.serviceURL, options);
+  }
+}
index c2190432ca02f5a4bd888431ecf7385c26cdb19f..e8e5b6d4e3a8b7e57e6ff260df6e5e6a6acb4adf 100644 (file)
@@ -87,6 +87,12 @@ export class TaskMessageService {
       this.i18n('Deleting'),
       this.i18n('delete'),
       this.i18n('Deleted')
+    ),
+    add: new TaskMessageOperation(this.i18n('Adding'), this.i18n('add'), this.i18n('Added')),
+    remove: new TaskMessageOperation(
+      this.i18n('Removing'),
+      this.i18n('remove'),
+      this.i18n('Removed')
     )
   };
 
@@ -125,6 +131,11 @@ export class TaskMessageService {
   };
 
   messages = {
+    // Host tasks
+    'host/add': this.newTaskMessage(this.commonOperations.add, (metadata) => this.host(metadata)),
+    'host/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
+      this.host(metadata)
+    ),
     // Pool tasks
     'pool/create': this.newTaskMessage(
       this.commonOperations.create,
@@ -348,6 +359,12 @@ export class TaskMessageService {
     return new TaskMessage(this.i18n, operation, involves, errors);
   }
 
+  host(metadata) {
+    return this.i18n(`host '{{hostname}}'`, {
+      hostname: metadata.hostname
+    });
+  }
+
   pool(metadata) {
     return this.i18n(`pool '{{pool_name}}'`, {
       pool_name: metadata.pool_name
index 8db88bd1f61782e8aaee3e850fad2efca028965e..66ba96cfb7ac8313dfab65b773de28915b84afb4 100644 (file)
@@ -120,3 +120,12 @@ def handle_send_command_error(component):
         yield
     except SendCommandError as e:
         raise DashboardException(e, component=component)
+
+
+@contextmanager
+def handle_orchestrator_error(component):
+    try:
+        yield
+    except RuntimeError as e:
+        # how to catch remote error e.g. NotImplementedError ?
+        raise DashboardException(e, component=component)
index e8b96cd6ba20e8192d5fc3b855c626dbd620e51b..bdc2fcbe22cee3306f899992a1a273f334bb1698 100644 (file)
@@ -69,7 +69,7 @@ class Ganesha(object):
     @staticmethod
     def _get_orch_nfs_instances():
         try:
-            return OrchClient().list_service_info("nfs")
+            return OrchClient.instance().services.list("nfs")
         except (RuntimeError, OrchestratorError, ImportError):
             return []
 
@@ -129,7 +129,7 @@ class Ganesha(object):
     @classmethod
     def reload_daemons(cls, cluster_id, daemons_id):
         logger.debug("[NFS] issued reload of daemons: %s", daemons_id)
-        if not OrchClient().available():
+        if not OrchClient.instance().available():
             logger.debug("[NFS] orchestrator not available")
             return
         reload_list = []
@@ -142,7 +142,7 @@ class Ganesha(object):
                 continue
             if daemons[cluster_id][daemon_id] == 1:
                 reload_list.append((cluster_id, daemon_id))
-        OrchClient().reload_service("nfs", reload_list)
+        OrchClient.instance().reload_service("nfs", reload_list)
 
     @classmethod
     def fsals_available(cls):
index c8cded19ab043ebf9105c8d43a62aa4dcd45f06a..8a4fd7f71a21e0871a13525fd23090444cb41b13 100644 (file)
@@ -86,7 +86,7 @@ class IscsiGatewaysConfig(object):
     def _load_config_from_orchestrator():
         config = {'gateways': {}}
         try:
-            instances = OrchClient().list_service_info("iscsi")
+            instances = OrchClient.instance().services.list("iscsi")
             for instance in instances:
                 config['gateways'][instance.nodename] = {
                     'service_url': instance.service_url
index 366ff7de735c531e18661c07618e1469f3840427..22746e8abd612efa13156aba038d473f511636fe 100644 (file)
 # -*- coding: utf-8 -*-
 from __future__ import absolute_import
 
+from orchestrator import InventoryFilter
 from orchestrator import OrchestratorClientMixin, raise_if_exception, OrchestratorError
 from .. import mgr, logger
+from ..tools import wraps
 
 
 # pylint: disable=abstract-method
-class OrchClient(OrchestratorClientMixin):
+class OrchestratorAPI(OrchestratorClientMixin):
     def __init__(self):
-        super(OrchClient, self).__init__()
+        super(OrchestratorAPI, self).__init__()
         self.set_mgr(mgr)
 
-    def list_service_info(self, service_type):
-        # type: (str) -> list
-        completion = self.describe_service(service_type, None, None)
-        self._orchestrator_wait([completion])
-        raise_if_exception(completion)
-        return completion.result
-
-    def available(self):
+    def status(self):
         try:
-            status, desc = super(OrchClient, self).available()
+            status, desc = super(OrchestratorAPI, self).available()
             logger.info("[ORCH] is orchestrator available: %s, %s", status, desc)
-            return status
+            return dict(available=status, description=desc)
         except (RuntimeError, OrchestratorError, ImportError):
-            return False
+            return dict(available=False,
+                        description='Orchestrator is unavailable for unknown reason')
+
+    def orchestrator_wait(self, completions):
+        return self._orchestrator_wait(completions)
+
+
+def wait_api_result(method):
+    @wraps(method)
+    def inner(self, *args, **kwargs):
+        completion = method(self, *args, **kwargs)
+        self.api.orchestrator_wait([completion])
+        raise_if_exception(completion)
+        return completion.result
+    return inner
+
+
+class ResourceManager(object):
+    def __init__(self, api):
+        self.api = api
+
+
+class HostManger(ResourceManager):
+
+    @wait_api_result
+    def list(self):
+        return self.api.get_hosts()
+
+    def get(self, hostname):
+        hosts = [host for host in self.list() if host.name == hostname]
+        return hosts[0] if hosts else None
+
+    @wait_api_result
+    def add(self, hostname):
+        return self.api.add_host(hostname)
+
+    @wait_api_result
+    def remove(self, hostname):
+        return self.api.remove_host(hostname)
 
-    def reload_service(self, service_type, service_ids):
+
+class InventoryManager(ResourceManager):
+
+    @wait_api_result
+    def list(self, hosts=None, refresh=False):
+        node_filter = InventoryFilter(nodes=hosts) if hosts else None
+        return self.api.get_inventory(node_filter=node_filter, refresh=refresh)
+
+
+class ServiceManager(ResourceManager):
+
+    @wait_api_result
+    def list(self, service_type=None, service_id=None, node_name=None):
+        return self.api.describe_service(service_type, service_id, node_name)
+
+    def reload(self, service_type, service_ids):
         if not isinstance(service_ids, list):
             service_ids = [service_ids]
 
-        completion_list = [self.service_action('reload', service_type,
-                                               service_name, service_id)
+        completion_list = [self.api.service_action('reload', service_type,
+                                                   service_name, service_id)
                            for service_name, service_id in service_ids]
-        self._orchestrator_wait(completion_list)
+        self.api.orchestrator_wait(completion_list)
         for c in completion_list:
             raise_if_exception(c)
+
+
+class OrchClient(object):
+
+    _instance = None
+
+    @classmethod
+    def instance(cls):
+        if cls._instance is None:
+            cls._instance = cls()
+        return cls._instance
+
+    def __init__(self):
+        self.api = OrchestratorAPI()
+
+        self.hosts = HostManger(self.api)
+        self.inventory = InventoryManager(self.api)
+        self.services = ServiceManager(self.api)
+
+    def available(self):
+        return self.status()['available']
+
+    def status(self):
+        return self.api.status()
index d4e23c0f575fc0bdf28595b47b361744d3748def..21b458522c53a30ddc54e25505738ca48cd888e6 100644 (file)
@@ -21,7 +21,7 @@ class IscsiTest(ControllerTestCase, CLICommandTestMixin):
 
     @classmethod
     def setup_server(cls):
-        OrchClient().available = lambda: False
+        OrchClient.instance().available = lambda: False
         mgr.rados.side_effect = None
         # pylint: disable=protected-access
         Iscsi._cp_config['tools.authenticate.on'] = False