]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: leverage features set from orchestrator
authorKiefer Chang <kiefer.chang@suse.com>
Wed, 6 May 2020 08:20:06 +0000 (16:20 +0800)
committerKiefer Chang <kiefer.chang@suse.com>
Mon, 17 Aug 2020 05:56:01 +0000 (13:56 +0800)
Orchestrator provides a list of supported functions. Using
this list to provide better UX and guard for unsupported
features.

- Backend: return 503 if required orchestrator features are
  missing, rather than calling non-implemented methods
  in orchestrator.
- Frontend:
  - Remove information modal when Orchestrator is not available.
  - Disable table action buttons if Orchestrator is not available
    or features are not supported.

Fixes: https://tracker.ceph.com/issues/45397
Signed-off-by: Kiefer Chang <kiefer.chang@suse.com>
35 files changed:
src/pybind/mgr/dashboard/controllers/host.py
src/pybind/mgr/dashboard/controllers/orchestrator.py
src/pybind/mgr/dashboard/controllers/osd.py
src/pybind/mgr/dashboard/controllers/service.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/fixtures/host_list_response.json [new file with mode: 0644]
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-devices/fixtures/inventory_list_response.json [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/fixtures/osd_list_response.json [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/dep-checker.service.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/dep-checker.service.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts
src/pybind/mgr/dashboard/services/ganesha.py
src/pybind/mgr/dashboard/services/orchestrator.py
src/pybind/mgr/dashboard/tests/test_host.py
src/pybind/mgr/dashboard/tests/test_orchestrator.py
src/pybind/mgr/dashboard/tests/test_osd.py

index db498c8aaad8983eeae721bb5a5afb97ce997a5d..100b676ef10c35d570175d328ac55e8541523078 100644 (file)
@@ -15,7 +15,7 @@ from .orchestrator import raise_if_no_orchestrator
 from .. import mgr
 from ..exceptions import DashboardException
 from ..security import Scope
-from ..services.orchestrator import OrchClient
+from ..services.orchestrator import OrchClient, OrchFeature
 from ..services.ceph_service import CephService
 from ..services.exception import handle_orchestrator_error
 
@@ -114,7 +114,7 @@ class Host(RESTController):
         from_orchestrator = 'orchestrator' in _sources
         return get_hosts(from_ceph, from_orchestrator)
 
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.HOST_LIST, OrchFeature.HOST_CREATE])
     @handle_orchestrator_error('host')
     @host_task('create', {'hostname': '{hostname}'})
     def create(self, hostname):  # pragma: no cover - requires realtime env
@@ -122,7 +122,7 @@ class Host(RESTController):
         self._check_orchestrator_host_op(orch_client, hostname, True)
         orch_client.hosts.add(hostname)
 
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.HOST_LIST, OrchFeature.HOST_DELETE])
     @handle_orchestrator_error('host')
     @host_task('delete', {'hostname': '{hostname}'})
     def delete(self, hostname):  # pragma: no cover - requires realtime env
@@ -161,7 +161,7 @@ class Host(RESTController):
         return CephService.get_smart_data_by_host(hostname)
 
     @RESTController.Resource('GET')
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.DAEMON_LIST])
     def daemons(self, hostname: str) -> List[dict]:
         orch = OrchClient.instance()
         daemons = orch.services.list_daemons(None, hostname)
@@ -175,7 +175,7 @@ class Host(RESTController):
         """
         return get_host(hostname)
 
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.HOST_LABEL_ADD, OrchFeature.HOST_LABEL_REMOVE])
     @handle_orchestrator_error('host')
     def set(self, hostname: str, labels: List[str]):
         """
index 14ddd39298aebe0faf9d9cfd0e3046547da90075..c26558c70b13672ee77c1d818ae73d40b32f742c 100644 (file)
@@ -11,7 +11,7 @@ from .. import mgr
 from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.exception import handle_orchestrator_error
-from ..services.orchestrator import OrchClient
+from ..services.orchestrator import OrchClient, OrchFeature
 from ..tools import TaskManager
 
 
@@ -53,16 +53,26 @@ def orchestrator_task(name, metadata, wait_for=2.0):
     return Task("orchestrator/{}".format(name), metadata, wait_for)
 
 
-def raise_if_no_orchestrator(method):
-    @wraps(method)
-    def inner(self, *args, **kwargs):
-        orch = OrchClient.instance()
-        if not orch.available():
-            raise DashboardException(code='orchestrator_status_unavailable',  # pragma: no cover
-                                     msg='Orchestrator is unavailable',
-                                     component='orchestrator',
-                                     http_status_code=503)
-        return method(self, *args, **kwargs)
+def raise_if_no_orchestrator(features=None):
+    def inner(method):
+        @wraps(method)
+        def _inner(self, *args, **kwargs):
+            orch = OrchClient.instance()
+            if not orch.available():
+                raise DashboardException(code='orchestrator_status_unavailable',  # pragma: no cover
+                                         msg='Orchestrator is unavailable',
+                                         component='orchestrator',
+                                         http_status_code=503)
+            if features is not None:
+                missing = orch.get_missing_features(features)
+                if missing:
+                    msg = 'Orchestrator feature(s) are unavailable: {}'.format(', '.join(missing))
+                    raise DashboardException(code='orchestrator_features_unavailable',
+                                             msg=msg,
+                                             component='orchestrator',
+                                             http_status_code=503)
+            return method(self, *args, **kwargs)
+        return _inner
     return inner
 
 
@@ -76,7 +86,7 @@ class Orchestrator(RESTController):
 
     @Endpoint(method='POST')
     @UpdatePermission
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.DEVICE_BLINK_LIGHT])
     @handle_orchestrator_error('osd')
     @orchestrator_task('identify_device', ['{hostname}', '{device}'])
     def identify_device(self, hostname, device, duration):  # pragma: no cover
@@ -102,7 +112,7 @@ class Orchestrator(RESTController):
 @ApiController('/orchestrator/inventory', Scope.HOSTS)
 class OrchestratorInventory(RESTController):
 
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.DEVICE_LIST])
     def list(self, hostname=None):
         orch = OrchClient.instance()
         hosts = [hostname] if hostname else None
index caff46e5eaadfad4897d352d0cb0c4e1a8892ccc..69e642501d68f63548ccd1763fcc2cf3dd76b4ca 100644 (file)
@@ -15,7 +15,7 @@ from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.ceph_service import CephService, SendCommandError
 from ..services.exception import handle_send_command_error, handle_orchestrator_error
-from ..services.orchestrator import OrchClient
+from ..services.orchestrator import OrchClient, OrchFeature
 from ..tools import str_to_bool
 try:
     from typing import Dict, List, Any, Union  # noqa: F401 pylint: disable=unused-import
@@ -154,7 +154,7 @@ class Osd(RESTController):
         }
 
     @DeletePermission
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.OSD_DELETE, OrchFeature.OSD_GET_REMOVE_STATUS])
     @handle_orchestrator_error('osd')
     @osd_task('delete', {'svc_id': '{svc_id}'})
     def delete(self, svc_id, preserve_id=None, force=None):  # pragma: no cover
@@ -258,7 +258,7 @@ class Osd(RESTController):
             'uuid': uuid,
         }
 
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.OSD_CREATE])
     @handle_orchestrator_error('osd')
     def _create_with_drive_groups(self, drive_groups):
         """Create OSDs with DriveGroups."""
@@ -326,7 +326,7 @@ class Osd(RESTController):
 
     @Endpoint('GET', query_params=['svc_ids'])
     @ReadPermission
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator()
     @handle_orchestrator_error('osd')
     def safe_to_delete(self, svc_ids):
         """
index baacff080ddb205f9677ad38394b3330f9d444e9..9e0294bdb618fcec862073c6a45d2df5dac85ee7 100644 (file)
@@ -7,7 +7,7 @@ from . import CreatePermission, DeletePermission
 from .orchestrator import raise_if_no_orchestrator
 from ..exceptions import DashboardException
 from ..security import Scope
-from ..services.orchestrator import OrchClient
+from ..services.orchestrator import OrchClient, OrchFeature
 from ..services.exception import handle_orchestrator_error
 
 
@@ -27,12 +27,12 @@ class Service(RESTController):
         """
         return ServiceSpec.KNOWN_SERVICE_TYPES
 
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.SERVICE_LIST])
     def list(self, service_name: Optional[str] = None) -> List[dict]:
         orch = OrchClient.instance()
         return [service.to_json() for service in orch.services.list(service_name)]
 
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.SERVICE_LIST])
     def get(self, service_name: str) -> List[dict]:
         orch = OrchClient.instance()
         services = orch.services.get(service_name)
@@ -41,14 +41,14 @@ class Service(RESTController):
         return services[0].to_json()
 
     @RESTController.Resource('GET')
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.DAEMON_LIST])
     def daemons(self, service_name: str) -> List[dict]:
         orch = OrchClient.instance()
         daemons = orch.services.list_daemons(service_name)
         return [d.to_json() for d in daemons]
 
     @CreatePermission
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.SERVICE_CREATE])
     @handle_orchestrator_error('service')
     @service_task('create', {'service_name': '{service_name}'})
     def create(self, service_spec: Dict, service_name: str):  # pylint: disable=W0613
@@ -64,7 +64,7 @@ class Service(RESTController):
             raise DashboardException(e, component='service')
 
     @DeletePermission
-    @raise_if_no_orchestrator
+    @raise_if_no_orchestrator([OrchFeature.SERVICE_DELETE])
     @handle_orchestrator_error('service')
     @service_task('delete', {'service_name': '{service_name}'})
     def delete(self, service_name: str):
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/fixtures/host_list_response.json b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/fixtures/host_list_response.json
new file mode 100644 (file)
index 0000000..8388197
--- /dev/null
@@ -0,0 +1,32 @@
+[
+  {
+    "hostname": "ceph-master",
+    "services": [
+      { "type": "mds", "id": "a" },
+      { "type": "mds", "id": "b" },
+      { "type": "mds", "id": "c" },
+      { "type": "mgr", "id": "x" },
+      { "type": "mon", "id": "a" },
+      { "type": "mon", "id": "b" },
+      { "type": "mon", "id": "c" },
+      { "type": "osd", "id": "0" },
+      { "type": "osd", "id": "1" },
+      { "type": "osd", "id": "2" }
+    ],
+    "ceph_version": "ceph version Development (no_version) pacific (dev)",
+    "addr": "",
+    "labels": [],
+    "service_type": "",
+    "sources": { "ceph": true, "orchestrator": false },
+    "status": ""
+  },
+  {
+    "ceph_version": "",
+    "services": [],
+    "sources": { "ceph": false, "orchestrator": true },
+    "hostname": "mgr0",
+    "addr": "mgr0",
+    "labels": [],
+    "status": ""
+  }
+]
index 0c2931f3af74847f97dc3385a597a5591f740fbe..391d87f7a0c9413209779c69967c36ceda2ede53 100644 (file)
@@ -7,11 +7,17 @@ import * as _ from 'lodash';
 import { ToastrModule } from 'ngx-toastr';
 import { of } from 'rxjs';
 
-import { configureTestBed } from '../../../../testing/unit-test-helper';
+import {
+  configureTestBed,
+  OrchestratorHelper,
+  TableActionHelper
+} from '../../../../testing/unit-test-helper';
 import { CoreModule } from '../../../core/core.module';
 import { HostService } from '../../../shared/api/host.service';
-import { ActionLabels } from '../../../shared/constants/app.constants';
-import { CdTableAction } from '../../../shared/models/cd-table-action';
+import { OrchestratorService } from '../../../shared/api/orchestrator.service';
+import { TableActionsComponent } from '../../../shared/datatable/table-actions/table-actions.component';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { OrchestratorFeature } from '../../../shared/models/orchestrator.enum';
 import { Permissions } from '../../../shared/models/permissions';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { SharedModule } from '../../../shared/shared.module';
@@ -23,6 +29,7 @@ describe('HostsComponent', () => {
   let component: HostsComponent;
   let fixture: ComponentFixture<HostsComponent>;
   let hostListSpy: jasmine.Spy;
+  let orchService: OrchestratorService;
 
   const fakeAuthStorageService = {
     getPermissions: () => {
@@ -41,14 +48,17 @@ describe('HostsComponent', () => {
       CephModule,
       CoreModule
     ],
-    providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }]
+    providers: [
+      { provide: AuthStorageService, useValue: fakeAuthStorageService },
+      TableActionsComponent
+    ]
   });
 
   beforeEach(() => {
     fixture = TestBed.createComponent(HostsComponent);
     component = fixture.componentInstance;
     hostListSpy = spyOn(TestBed.inject(HostService), 'list');
-    fixture.detectChanges();
+    orchService = TestBed.inject(OrchestratorService);
   });
 
   it('should create', () => {
@@ -79,7 +89,9 @@ describe('HostsComponent', () => {
       }
     ];
 
+    OrchestratorHelper.mockStatus(true);
     hostListSpy.and.callFake(() => of(payload));
+    fixture.detectChanges();
 
     return fixture.whenStable().then(() => {
       fixture.detectChanges();
@@ -91,80 +103,135 @@ describe('HostsComponent', () => {
     });
   });
 
-  describe('test edit button', () => {
-    let tableAction: CdTableAction;
+  describe('table actions', () => {
+    const fakeHosts = require('./fixtures/host_list_response.json');
 
     beforeEach(() => {
-      tableAction = _.find(component.tableActions, { name: ActionLabels.EDIT });
+      hostListSpy.and.callFake(() => of(fakeHosts));
     });
 
-    it('should disable button and return message (not managed by Orchestrator)', () => {
-      component.selection.add({
-        sources: {
-          ceph: true,
-          orchestrator: false
-        }
-      });
-      expect(tableAction.disable(component.selection)).toBeTruthy();
-      expect(component.getEditDisableDesc(component.selection)).toBe(
-        'Host editing is disabled because the selected host is not managed by Orchestrator.'
-      );
-    });
-
-    it('should disable button and return true (no selection)', () => {
-      expect(tableAction.disable(component.selection)).toBeTruthy();
-      expect(component.getEditDisableDesc(component.selection)).toBeTruthy();
-    });
-
-    it('should enable button and return false (managed by Orchestrator)', () => {
-      component.selection.add({
-        sources: {
-          ceph: false,
-          orchestrator: true
-        }
-      });
-      expect(tableAction.disable(component.selection)).toBeFalsy();
-      expect(component.getEditDisableDesc(component.selection)).toBeFalsy();
-    });
-  });
+    const testTableActions = async (
+      orch: boolean,
+      features: OrchestratorFeature[],
+      tests: { selectRow?: number; expectResults: any }[]
+    ) => {
+      OrchestratorHelper.mockStatus(orch, features);
+      fixture.detectChanges();
+      await fixture.whenStable();
 
-  describe('getDeleteDisableDesc', () => {
-    it('should return message (not managed by Orchestrator)', () => {
-      component.selection.add({
-        sources: {
-          ceph: false,
-          orchestrator: true
+      for (const test of tests) {
+        if (test.selectRow) {
+          component.selection = new CdTableSelection();
+          component.selection.selected = [test.selectRow];
         }
-      });
-      component.selection.add({
-        sources: {
-          ceph: true,
-          orchestrator: false
+        await TableActionHelper.verifyTableActions(
+          fixture,
+          component.tableActions,
+          test.expectResults
+        );
+      }
+    };
+
+    it('should have correct states when Orchestrator is enabled', async () => {
+      const tests = [
+        {
+          expectResults: {
+            Create: { disabled: false, disableDesc: '' },
+            Edit: { disabled: true, disableDesc: '' },
+            Delete: { disabled: true, disableDesc: '' }
+          }
+        },
+        {
+          selectRow: fakeHosts[0], // non-orchestrator host
+          expectResults: {
+            Create: { disabled: false, disableDesc: '' },
+            Edit: { disabled: true, disableDesc: component.messages.nonOrchHost },
+            Delete: { disabled: true, disableDesc: component.messages.nonOrchHost }
+          }
+        },
+        {
+          selectRow: fakeHosts[1], // orchestrator host
+          expectResults: {
+            Create: { disabled: false, disableDesc: '' },
+            Edit: { disabled: false, disableDesc: '' },
+            Delete: { disabled: false, disableDesc: '' }
+          }
         }
-      });
-      expect(component.getDeleteDisableDesc(component.selection)).toBe(
-        'Host deletion is disabled because a selected host is not managed by Orchestrator.'
-      );
+      ];
+
+      const features = [
+        OrchestratorFeature.HOST_CREATE,
+        OrchestratorFeature.HOST_LABEL_ADD,
+        OrchestratorFeature.HOST_DELETE,
+        OrchestratorFeature.HOST_LABEL_REMOVE
+      ];
+      await testTableActions(true, features, tests);
     });
 
-    it('should return true (no selection)', () => {
-      expect(component.getDeleteDisableDesc(component.selection)).toBeTruthy();
+    it('should have correct states when Orchestrator is disabled', async () => {
+      const resultNoOrchestrator = {
+        disabled: true,
+        disableDesc: orchService.disableMessages.noOrchestrator
+      };
+      const tests = [
+        {
+          expectResults: {
+            Create: resultNoOrchestrator,
+            Edit: { disabled: true, disableDesc: '' },
+            Delete: { disabled: true, disableDesc: '' }
+          }
+        },
+        {
+          selectRow: fakeHosts[0], // non-orchestrator host
+          expectResults: {
+            Create: resultNoOrchestrator,
+            Edit: { disabled: true, disableDesc: component.messages.nonOrchHost },
+            Delete: { disabled: true, disableDesc: component.messages.nonOrchHost }
+          }
+        },
+        {
+          selectRow: fakeHosts[1], // orchestrator host
+          expectResults: {
+            Create: resultNoOrchestrator,
+            Edit: resultNoOrchestrator,
+            Delete: resultNoOrchestrator
+          }
+        }
+      ];
+      await testTableActions(false, [], tests);
     });
 
-    it('should return false (managed by Orchestrator)', () => {
-      component.selection.add({
-        sources: {
-          ceph: false,
-          orchestrator: true
-        }
-      });
-      component.selection.add({
-        sources: {
-          ceph: false,
-          orchestrator: true
+    it('should have correct states when Orchestrator features are missing', async () => {
+      const resultMissingFeatures = {
+        disabled: true,
+        disableDesc: orchService.disableMessages.missingFeature
+      };
+      const tests = [
+        {
+          expectResults: {
+            Create: resultMissingFeatures,
+            Edit: { disabled: true, disableDesc: '' },
+            Delete: { disabled: true, disableDesc: '' }
+          }
+        },
+        {
+          selectRow: fakeHosts[0], // non-orchestrator host
+          expectResults: {
+            Create: resultMissingFeatures,
+            Edit: { disabled: true, disableDesc: component.messages.nonOrchHost },
+            Delete: { disabled: true, disableDesc: component.messages.nonOrchHost }
+          }
+        },
+        {
+          selectRow: fakeHosts[1], // orchestrator host
+          expectResults: {
+            Create: resultMissingFeatures,
+            Edit: resultMissingFeatures,
+            Delete: resultMissingFeatures
+          }
         }
-      });
-      expect(component.getDeleteDisableDesc(component.selection)).toBeFalsy();
+      ];
+      await testTableActions(true, [], tests);
     });
   });
 });
index 225eba56918d6214f88d66c200456d8185ccc1c6..a095f6c6b77bb23ad346fcbdbeedec939853c348 100644 (file)
@@ -2,8 +2,10 @@ import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
 import { Router } from '@angular/router';
 
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import * as _ from 'lodash';
 
 import { HostService } from '../../../shared/api/host.service';
+import { OrchestratorService } from '../../../shared/api/orchestrator.service';
 import { ListWithDetails } from '../../../shared/classes/list-with-details.class';
 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component';
@@ -17,11 +19,12 @@ 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 { OrchestratorFeature } from '../../../shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '../../../shared/models/orchestrator.interface';
 import { Permissions } from '../../../shared/models/permissions';
 import { CephShortVersionPipe } from '../../../shared/pipes/ceph-short-version.pipe';
 import { JoinPipe } from '../../../shared/pipes/join.pipe';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
-import { DepCheckerService } from '../../../shared/services/dep-checker.service';
 import { ModalService } from '../../../shared/services/modal.service';
 import { NotificationService } from '../../../shared/services/notification.service';
 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
@@ -50,6 +53,17 @@ export class HostsComponent extends ListWithDetails implements OnInit {
   selection = new CdTableSelection();
   modalRef: NgbModalRef;
 
+  messages = {
+    nonOrchHost: $localize`The feature is disabled because the selected host is not managed by Orchestrator.`
+  };
+
+  orchStatus: OrchestratorStatus;
+  actionOrchFeatures = {
+    create: [OrchestratorFeature.HOST_CREATE],
+    edit: [OrchestratorFeature.HOST_LABEL_ADD, OrchestratorFeature.HOST_LABEL_REMOVE],
+    delete: [OrchestratorFeature.HOST_DELETE]
+  };
+
   constructor(
     private authStorageService: AuthStorageService,
     private hostService: HostService,
@@ -60,8 +74,8 @@ export class HostsComponent extends ListWithDetails implements OnInit {
     private modalService: ModalService,
     private taskWrapper: TaskWrapperService,
     private router: Router,
-    private depCheckerService: DepCheckerService,
-    private notificationService: NotificationService
+    private notificationService: NotificationService,
+    private orchService: OrchestratorService
   ) {
     super();
     this.permissions = this.authStorageService.getPermissions();
@@ -70,41 +84,22 @@ export class HostsComponent extends ListWithDetails implements OnInit {
         name: this.actionLabels.CREATE,
         permission: 'create',
         icon: Icons.add,
-        click: () => {
-          this.depCheckerService.checkOrchestratorOrModal(
-            this.actionLabels.CREATE,
-            $localize`Host`,
-            () => {
-              this.router.navigate([this.urlBuilder.getCreate()]);
-            }
-          );
-        }
+        click: () => this.router.navigate([this.urlBuilder.getCreate()]),
+        disable: (selection: CdTableSelection) => this.getDisable('create', selection)
       },
       {
         name: this.actionLabels.EDIT,
         permission: 'update',
         icon: Icons.edit,
-        click: () => {
-          this.depCheckerService.checkOrchestratorOrModal(
-            this.actionLabels.EDIT,
-            $localize`Host`,
-            () => this.editAction()
-          );
-        },
-        disable: this.getEditDisableDesc.bind(this)
+        click: () => this.editAction(),
+        disable: (selection: CdTableSelection) => this.getDisable('edit', selection)
       },
       {
         name: this.actionLabels.DELETE,
         permission: 'delete',
         icon: Icons.destroy,
-        click: () => {
-          this.depCheckerService.checkOrchestratorOrModal(
-            this.actionLabels.DELETE,
-            $localize`Host`,
-            () => this.deleteAction()
-          );
-        },
-        disable: this.getDeleteDisableDesc.bind(this)
+        click: () => this.deleteAction(),
+        disable: (selection: CdTableSelection) => this.getDisable('delete', selection)
       }
     ];
   }
@@ -135,6 +130,9 @@ export class HostsComponent extends ListWithDetails implements OnInit {
         pipe: this.cephShortVersionPipe
       }
     ];
+    this.orchService.status().subscribe((status: OrchestratorStatus) => {
+      this.orchStatus = status;
+    });
   }
 
   updateSelection(selection: CdTableSelection) {
@@ -181,16 +179,19 @@ export class HostsComponent extends ListWithDetails implements OnInit {
     });
   }
 
-  getEditDisableDesc(selection: CdTableSelection): boolean | string {
-    if (selection?.hasSingleSelection) {
-      if (!selection?.first().sources.orchestrator) {
-        return $localize`Host editing is disabled because the selected host is not managed by Orchestrator.`;
+  getDisable(action: 'create' | 'edit' | 'delete', selection: CdTableSelection): boolean | string {
+    if (action === 'delete' || action === 'edit') {
+      if (!selection?.hasSingleSelection) {
+        return true;
+      }
+      if (!_.every(selection.selected, 'sources.orchestrator')) {
+        return this.messages.nonOrchHost;
       }
-
-      return false;
     }
-
-    return true;
+    return this.orchService.getTableActionDisableDesc(
+      this.orchStatus,
+      this.actionOrchFeatures[action]
+    );
   }
 
   deleteAction() {
@@ -207,18 +208,6 @@ export class HostsComponent extends ListWithDetails implements OnInit {
     });
   }
 
-  getDeleteDisableDesc(selection: CdTableSelection): boolean | string {
-    if (selection?.hasSelection) {
-      if (!selection.selected.every((selected) => selected.sources.orchestrator)) {
-        return $localize`Host deletion is disabled because a selected host is not managed by Orchestrator.`;
-      }
-
-      return false;
-    }
-
-    return true;
-  }
-
   getHosts(context: CdTableFetchDataContext) {
     if (this.isLoadingHosts) {
       return;
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/fixtures/inventory_list_response.json b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/fixtures/inventory_list_response.json
new file mode 100644 (file)
index 0000000..8a6986a
--- /dev/null
@@ -0,0 +1,324 @@
+[
+  {
+    "name": "mgr0",
+    "addr": "mgr0",
+    "devices": [
+      {
+        "rejected_reasons": [],
+        "available": true,
+        "path": "/dev/sda",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "ATA",
+          "model": "QEMU HARDDISK",
+          "rev": "2.5+",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "512",
+          "rotational": "0",
+          "nr_requests": "64",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {},
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 10737418240.0,
+          "human_readable_size": "10.00 GB",
+          "path": "/dev/sda",
+          "locked": 0
+        },
+        "lvs": [],
+        "human_readable_type": "ssd",
+        "device_id": "QEMU_HARDDISK_mgr0-1-ssd",
+        "osd_ids": []
+      },
+      {
+        "rejected_reasons": [],
+        "available": true,
+        "path": "/dev/sdb",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "ATA",
+          "model": "QEMU HARDDISK",
+          "rev": "2.5+",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "512",
+          "rotational": "0",
+          "nr_requests": "64",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {},
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 10737418240.0,
+          "human_readable_size": "10.00 GB",
+          "path": "/dev/sdb",
+          "locked": 0
+        },
+        "lvs": [],
+        "human_readable_type": "ssd",
+        "device_id": "QEMU_HARDDISK_mgr0-2-ssd",
+        "osd_ids": []
+      },
+      {
+        "rejected_reasons": [],
+        "available": true,
+        "path": "/dev/sdc",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "ATA",
+          "model": "QEMU HARDDISK",
+          "rev": "2.5+",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "512",
+          "rotational": "1",
+          "nr_requests": "64",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {},
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 21474836480.0,
+          "human_readable_size": "20.00 GB",
+          "path": "/dev/sdc",
+          "locked": 0
+        },
+        "lvs": [],
+        "human_readable_type": "hdd",
+        "device_id": "QEMU_HARDDISK_mgr0-3-hdd",
+        "osd_ids": []
+      },
+      {
+        "rejected_reasons": [],
+        "available": true,
+        "path": "/dev/sdd",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "ATA",
+          "model": "QEMU HARDDISK",
+          "rev": "2.5+",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "512",
+          "rotational": "1",
+          "nr_requests": "64",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {},
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 21474836480.0,
+          "human_readable_size": "20.00 GB",
+          "path": "/dev/sdd",
+          "locked": 0
+        },
+        "lvs": [],
+        "human_readable_type": "hdd",
+        "device_id": "QEMU_HARDDISK_mgr0-4-hdd",
+        "osd_ids": []
+      },
+      {
+        "rejected_reasons": ["locked"],
+        "available": false,
+        "path": "/dev/vda",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "0x1af4",
+          "model": "",
+          "rev": "",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "0",
+          "rotational": "1",
+          "nr_requests": "256",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {
+            "vda1": {
+              "start": "2048",
+              "sectors": "20969472",
+              "sectorsize": 512,
+              "size": 10736369664.0,
+              "human_readable_size": "10.00 GB",
+              "holders": []
+            }
+          },
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 11811160064.0,
+          "human_readable_size": "11.00 GB",
+          "path": "/dev/vda",
+          "locked": 1
+        },
+        "lvs": [],
+        "human_readable_type": "hdd",
+        "device_id": "",
+        "osd_ids": []
+      }
+    ],
+    "labels": []
+  },
+  {
+    "name": "osd0",
+    "addr": "osd0",
+    "devices": [
+      {
+        "rejected_reasons": [],
+        "available": true,
+        "path": "/dev/sda",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "ATA",
+          "model": "QEMU HARDDISK",
+          "rev": "2.5+",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "512",
+          "rotational": "0",
+          "nr_requests": "64",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {},
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 10737418240.0,
+          "human_readable_size": "10.00 GB",
+          "path": "/dev/sda",
+          "locked": 0
+        },
+        "lvs": [],
+        "human_readable_type": "ssd",
+        "device_id": "QEMU_HARDDISK_osd0-1-ssd",
+        "osd_ids": []
+      },
+      {
+        "rejected_reasons": [],
+        "available": true,
+        "path": "/dev/sdb",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "ATA",
+          "model": "QEMU HARDDISK",
+          "rev": "2.5+",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "512",
+          "rotational": "0",
+          "nr_requests": "64",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {},
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 10737418240.0,
+          "human_readable_size": "10.00 GB",
+          "path": "/dev/sdb",
+          "locked": 0
+        },
+        "lvs": [],
+        "human_readable_type": "ssd",
+        "device_id": "QEMU_HARDDISK_osd0-2-ssd",
+        "osd_ids": []
+      },
+      {
+        "rejected_reasons": [],
+        "available": true,
+        "path": "/dev/sdc",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "ATA",
+          "model": "QEMU HARDDISK",
+          "rev": "2.5+",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "512",
+          "rotational": "1",
+          "nr_requests": "64",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {},
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 21474836480.0,
+          "human_readable_size": "20.00 GB",
+          "path": "/dev/sdc",
+          "locked": 0
+        },
+        "lvs": [],
+        "human_readable_type": "hdd",
+        "device_id": "QEMU_HARDDISK_osd0-3-hdd",
+        "osd_ids": []
+      },
+      {
+        "rejected_reasons": [],
+        "available": true,
+        "path": "/dev/sdd",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "ATA",
+          "model": "QEMU HARDDISK",
+          "rev": "2.5+",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "512",
+          "rotational": "1",
+          "nr_requests": "64",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {},
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 21474836480.0,
+          "human_readable_size": "20.00 GB",
+          "path": "/dev/sdd",
+          "locked": 0
+        },
+        "lvs": [],
+        "human_readable_type": "hdd",
+        "device_id": "QEMU_HARDDISK_osd0-4-hdd",
+        "osd_ids": []
+      },
+      {
+        "rejected_reasons": ["locked"],
+        "available": false,
+        "path": "/dev/vda",
+        "sys_api": {
+          "removable": "0",
+          "ro": "0",
+          "vendor": "0x1af4",
+          "model": "",
+          "rev": "",
+          "sas_address": "",
+          "sas_device_handle": "",
+          "support_discard": "0",
+          "rotational": "1",
+          "nr_requests": "256",
+          "scheduler_mode": "mq-deadline",
+          "partitions": {
+            "vda1": {
+              "start": "2048",
+              "sectors": "20969472",
+              "sectorsize": 512,
+              "size": 10736369664.0,
+              "human_readable_size": "10.00 GB",
+              "holders": []
+            }
+          },
+          "sectors": 0,
+          "sectorsize": "512",
+          "size": 11811160064.0,
+          "human_readable_size": "11.00 GB",
+          "path": "/dev/vda",
+          "locked": 1
+        },
+        "lvs": [],
+        "human_readable_type": "hdd",
+        "device_id": "",
+        "osd_ids": []
+      }
+    ],
+    "labels": []
+  }
+]
index 1ce48f6fd00fa01349ebc33e02d950c3cb9a2066..b05f37af3199b25dea63b533bea58f01a883a85e 100644 (file)
@@ -1,18 +1,44 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
 
 import * as _ from 'lodash';
 import { ToastrModule } from 'ngx-toastr';
 
 import { configureTestBed } from '../../../../../testing/unit-test-helper';
+import { OrchestratorService } from '../../../../shared/api/orchestrator.service';
+import { TableActionsComponent } from '../../../../shared/datatable/table-actions/table-actions.component';
+import { CdTableAction } from '../../../../shared/models/cd-table-action';
+import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
+import { OrchestratorFeature } from '../../../../shared/models/orchestrator.enum';
+import { Permissions } from '../../../../shared/models/permissions';
+import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
 import { SharedModule } from '../../../../shared/shared.module';
 import { InventoryDevicesComponent } from './inventory-devices.component';
 
 describe('InventoryDevicesComponent', () => {
   let component: InventoryDevicesComponent;
   let fixture: ComponentFixture<InventoryDevicesComponent>;
+  let orchService: OrchestratorService;
+
+  const fakeAuthStorageService = {
+    getPermissions: () => {
+      return new Permissions({ osd: ['read', 'update', 'create', 'delete'] });
+    }
+  };
+
+  const mockOrchStatus = (available: boolean, features?: OrchestratorFeature[]) => {
+    const orchStatus = { available: available, description: '', features: {} };
+    if (features) {
+      features.forEach((feature: OrchestratorFeature) => {
+        orchStatus.features[feature] = { available: true };
+      });
+    }
+    component.orchStatus = orchStatus;
+  };
 
   configureTestBed({
     imports: [
@@ -20,15 +46,20 @@ describe('InventoryDevicesComponent', () => {
       FormsModule,
       HttpClientTestingModule,
       SharedModule,
+      RouterTestingModule,
       ToastrModule.forRoot()
     ],
+    providers: [
+      { provide: AuthStorageService, useValue: fakeAuthStorageService },
+      TableActionsComponent
+    ],
     declarations: [InventoryDevicesComponent]
   });
 
   beforeEach(() => {
     fixture = TestBed.createComponent(InventoryDevicesComponent);
     component = fixture.componentInstance;
-    fixture.detectChanges();
+    orchService = TestBed.inject(OrchestratorService);
   });
 
   it('should create', () => {
@@ -38,4 +69,114 @@ describe('InventoryDevicesComponent', () => {
   it('should have columns that are sortable', () => {
     expect(component.columns.every((column) => Boolean(column.prop))).toBeTruthy();
   });
+
+  describe('table actions', () => {
+    const fakeDevices = require('./fixtures/inventory_list_response.json');
+
+    beforeEach(() => {
+      component.devices = fakeDevices;
+      component.selectionType = 'single';
+      fixture.detectChanges();
+    });
+
+    const verifyTableActions = async (
+      tableActions: CdTableAction[],
+      expectResult: {
+        [action: string]: { disabled: boolean; disableDesc: string };
+      }
+    ) => {
+      fixture.detectChanges();
+      await fixture.whenStable();
+      const tableActionElement = fixture.debugElement.query(By.directive(TableActionsComponent));
+      // There is actually only one action for now
+      const actions = {};
+      tableActions.forEach((action) => {
+        const actionElement = tableActionElement.query(By.css('button'));
+        actions[action.name] = {
+          disabled: actionElement.classes.disabled,
+          disableDesc: actionElement.properties.title
+        };
+      });
+      expect(actions).toEqual(expectResult);
+    };
+
+    const testTableActions = async (
+      orch: boolean,
+      features: OrchestratorFeature[],
+      tests: { selectRow?: number; expectResults: any }[]
+    ) => {
+      mockOrchStatus(orch, features);
+      fixture.detectChanges();
+      await fixture.whenStable();
+
+      for (const test of tests) {
+        if (test.selectRow) {
+          component.selection = new CdTableSelection();
+          component.selection.selected = [test.selectRow];
+        }
+        await verifyTableActions(component.tableActions, test.expectResults);
+      }
+    };
+
+    it('should have correct states when Orchestrator is enabled', async () => {
+      const tests = [
+        {
+          expectResults: {
+            Identify: { disabled: true, disableDesc: '' }
+          }
+        },
+        {
+          selectRow: fakeDevices[0],
+          expectResults: {
+            Identify: { disabled: false, disableDesc: '' }
+          }
+        }
+      ];
+
+      const features = [OrchestratorFeature.DEVICE_BLINK_LIGHT];
+      await testTableActions(true, features, tests);
+    });
+
+    it('should have correct states when Orchestrator is disabled', async () => {
+      const resultNoOrchestrator = {
+        disabled: true,
+        disableDesc: orchService.disableMessages.noOrchestrator
+      };
+      const tests = [
+        {
+          expectResults: {
+            Identify: { disabled: true, disableDesc: '' }
+          }
+        },
+        {
+          selectRow: fakeDevices[0],
+          expectResults: {
+            Identify: resultNoOrchestrator
+          }
+        }
+      ];
+      await testTableActions(false, [], tests);
+    });
+
+    it('should have correct states when Orchestrator features are missing', async () => {
+      const resultMissingFeatures = {
+        disabled: true,
+        disableDesc: orchService.disableMessages.missingFeature
+      };
+      const expectResults = [
+        {
+          expectResults: {
+            Identify: { disabled: true, disableDesc: '' }
+          }
+        },
+        {
+          selectRow: fakeDevices[0],
+          expectResults: {
+            Identify: resultMissingFeatures
+          }
+        }
+      ];
+      await testTableActions(true, [], expectResults);
+    });
+  });
 });
index 4b99c747396b3227dc798e7e76cb8d84d49b037e..d6ec52e680eaa485e6e57cd13124f39c2db58351 100644 (file)
@@ -21,6 +21,8 @@ import { CdTableAction } from '../../../../shared/models/cd-table-action';
 import { CdTableColumn } from '../../../../shared/models/cd-table-column';
 import { CdTableColumnFiltersChange } from '../../../../shared/models/cd-table-column-filters-change';
 import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
+import { OrchestratorFeature } from '../../../../shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '../../../../shared/models/orchestrator.interface';
 import { Permission } from '../../../../shared/models/permissions';
 import { DimlessBinaryPipe } from '../../../../shared/pipes/dimless-binary.pipe';
 import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
@@ -67,6 +69,12 @@ export class InventoryDevicesComponent implements OnInit, OnDestroy {
   tableActions: CdTableAction[];
   fetchInventorySub: Subscription;
 
+  @Input() orchStatus: OrchestratorStatus = undefined;
+
+  actionOrchFeatures = {
+    identify: [OrchestratorFeature.DEVICE_BLINK_LIGHT]
+  };
+
   constructor(
     private authStorageService: AuthStorageService,
     private dimlessBinary: DimlessBinaryPipe,
@@ -83,7 +91,7 @@ export class InventoryDevicesComponent implements OnInit, OnDestroy {
         icon: Icons.show,
         click: () => this.identifyDevice(),
         name: $localize`Identify`,
-        disable: () => !this.selection.hasSingleSelection,
+        disable: (selection: CdTableSelection) => this.getDisable('identify', selection),
         canBePrimary: (selection: CdTableSelection) => !selection.hasSingleSelection,
         visible: () => _.isString(this.selectionType)
       }
@@ -175,6 +183,16 @@ export class InventoryDevicesComponent implements OnInit, OnDestroy {
     this.filterChange.emit(event);
   }
 
+  getDisable(action: 'identify', selection: CdTableSelection): boolean | string {
+    if (!selection.hasSingleSelection) {
+      return true;
+    }
+    return this.orchService.getTableActionDisableDesc(
+      this.orchStatus,
+      this.actionOrchFeatures[action]
+    );
+  }
+
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
index 70a0d0ef50e1a3eab1d2c48170d58f8ba569a7b7..e05f6dc59e1779c59c66acc499725a753e89835d 100644 (file)
@@ -1,12 +1,13 @@
-<cd-orchestrator-doc-panel *ngIf="!hasOrchestrator"></cd-orchestrator-doc-panel>
-<ng-container *ngIf="hasOrchestrator">
+<cd-orchestrator-doc-panel *ngIf="!orchStatus?.available"></cd-orchestrator-doc-panel>
+<ng-container *ngIf="orchStatus?.available">
   <legend i18n>Devices</legend>
   <div class="row">
     <div class="col-md-12">
       <cd-inventory-devices [devices]="devices"
                             [hiddenColumns]="hostname === undefined ? [] : ['hostname']"
                             selectionType="single"
-                            (fetchInventory)="refresh()">
+                            (fetchInventory)="refresh()"
+                            [orchStatus]="orchStatus">
       </cd-inventory-devices>
     </div>
   </div>
index da0f1a541a14071a98cecdc015fa137207d0e27e..2c2ba53c00e5f0aa18a391f16ea70d947ad9dd6e 100644 (file)
@@ -2,6 +2,7 @@ import { Component, Input, OnChanges, OnInit } from '@angular/core';
 
 import { OrchestratorService } from '../../../shared/api/orchestrator.service';
 import { Icons } from '../../../shared/enum/icons.enum';
+import { OrchestratorStatus } from '../../../shared/models/orchestrator.interface';
 import { InventoryDevice } from './inventory-devices/inventory-device.model';
 
 @Component({
@@ -15,7 +16,7 @@ export class InventoryComponent implements OnChanges, OnInit {
 
   icons = Icons;
 
-  hasOrchestrator = false;
+  orchStatus: OrchestratorStatus;
 
   devices: Array<InventoryDevice> = [];
 
@@ -23,7 +24,7 @@ export class InventoryComponent implements OnChanges, OnInit {
 
   ngOnInit() {
     this.orchService.status().subscribe((status) => {
-      this.hasOrchestrator = status.available;
+      this.orchStatus = status;
       if (status.available) {
         this.getInventory();
       }
@@ -31,7 +32,7 @@ export class InventoryComponent implements OnChanges, OnInit {
   }
 
   ngOnChanges() {
-    if (this.hasOrchestrator) {
+    if (this.orchStatus) {
       this.devices = [];
       this.getInventory();
     }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/fixtures/osd_list_response.json b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/fixtures/osd_list_response.json
new file mode 100644 (file)
index 0000000..83590a1
--- /dev/null
@@ -0,0 +1,602 @@
+[
+  {
+    "osd": 0,
+    "up": 1,
+    "in": 1,
+    "weight": 1.0,
+    "primary_affinity": 1.0,
+    "last_clean_begin": 0,
+    "last_clean_end": 0,
+    "up_from": 8,
+    "up_thru": 143,
+    "down_at": 0,
+    "lost_at": 0,
+    "public_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 9066, "addr": "192.168.2.106:6802" },
+        { "type": "v1", "nonce": 9066, "addr": "192.168.2.106:6803" }
+      ]
+    },
+    "cluster_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 9066, "addr": "192.168.2.106:6804" },
+        { "type": "v1", "nonce": 9066, "addr": "192.168.2.106:6805" }
+      ]
+    },
+    "heartbeat_back_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 9066, "addr": "192.168.2.106:6808" },
+        { "type": "v1", "nonce": 9066, "addr": "192.168.2.106:6809" }
+      ]
+    },
+    "heartbeat_front_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 9066, "addr": "192.168.2.106:6806" },
+        { "type": "v1", "nonce": 9066, "addr": "192.168.2.106:6807" }
+      ]
+    },
+    "state": ["exists", "up"],
+    "uuid": "7fd350c1-ff37-4b89-b4a7-774219e78cbb",
+    "public_addr": "192.168.2.106:6803/9066",
+    "cluster_addr": "192.168.2.106:6805/9066",
+    "heartbeat_back_addr": "192.168.2.106:6809/9066",
+    "heartbeat_front_addr": "192.168.2.106:6807/9066",
+    "id": 0,
+    "osd_stats": {
+      "osd": 0,
+      "up_from": 8,
+      "seq": 34359740004,
+      "num_pgs": 201,
+      "num_osds": 1,
+      "num_per_pool_osds": 1,
+      "num_per_pool_omap_osds": 1,
+      "kb": 105906168,
+      "kb_used": 2099028,
+      "kb_used_data": 1876,
+      "kb_used_omap": 0,
+      "kb_used_meta": 1048576,
+      "kb_avail": 103807140,
+      "statfs": {
+        "total": 108447916032,
+        "available": 106298511360,
+        "internally_reserved": 1073741824,
+        "allocated": 1921024,
+        "data_stored": 748530,
+        "data_compressed": 0,
+        "data_compressed_allocated": 0,
+        "data_compressed_original": 0,
+        "omap_allocated": 0,
+        "internal_metadata": 1073741824
+      },
+      "hb_peers": [1, 2],
+      "snap_trim_queue_len": 0,
+      "num_snap_trimming": 0,
+      "num_shards_repaired": 0,
+      "op_queue_age_hist": { "histogram": [], "upper_bound": 1 },
+      "perf_stat": {
+        "commit_latency_ms": 0.0,
+        "apply_latency_ms": 0.0,
+        "commit_latency_ns": 0,
+        "apply_latency_ns": 0
+      },
+      "alerts": []
+    },
+    "tree": {
+      "id": 0,
+      "device_class": "ssd",
+      "type": "osd",
+      "type_id": 0,
+      "crush_weight": 0.0985870361328125,
+      "depth": 2,
+      "pool_weights": {},
+      "exists": 1,
+      "status": "up",
+      "reweight": 1.0,
+      "primary_affinity": 1.0,
+      "name": "osd.0"
+    },
+    "host": {
+      "id": -3,
+      "name": "ceph-master",
+      "type": "host",
+      "type_id": 1,
+      "pool_weights": {},
+      "children": [2, 1, 0]
+    },
+    "stats": {
+      "op_w": 0.0,
+      "op_in_bytes": 0.0,
+      "op_r": 0.0,
+      "op_out_bytes": 0.0,
+      "numpg": 201,
+      "stat_bytes": 108447916032,
+      "stat_bytes_used": 2149404672
+    },
+    "stats_history": {
+      "op_w": [
+        [1594973071.815675, 0.0],
+        [1594973076.8181818, 0.0],
+        [1594973081.8206801, 0.0],
+        [1594973086.8231986, 0.0],
+        [1594973091.8258255, 0.0],
+        [1594973096.8285067, 0.0],
+        [1594973101.830774, 0.0],
+        [1594973106.8332067, 0.0],
+        [1594973111.8377645, 0.0],
+        [1594973116.8413265, 0.0],
+        [1594973121.8436713, 0.0],
+        [1594973126.846079, 0.0],
+        [1594973131.8485043, 0.0],
+        [1594973136.8509178, 0.0],
+        [1594973141.8532503, 0.0],
+        [1594973146.8557014, 0.0],
+        [1594973151.857818, 0.0],
+        [1594973156.8602881, 0.0],
+        [1594973161.862781, 0.0]
+      ],
+      "op_in_bytes": [
+        [1594973071.815675, 0.0],
+        [1594973076.8181818, 0.0],
+        [1594973081.8206801, 0.0],
+        [1594973086.8231986, 0.0],
+        [1594973091.8258255, 0.0],
+        [1594973096.8285067, 0.0],
+        [1594973101.830774, 0.0],
+        [1594973106.8332067, 0.0],
+        [1594973111.8377645, 0.0],
+        [1594973116.8413265, 0.0],
+        [1594973121.8436713, 0.0],
+        [1594973126.846079, 0.0],
+        [1594973131.8485043, 0.0],
+        [1594973136.8509178, 0.0],
+        [1594973141.8532503, 0.0],
+        [1594973146.8557014, 0.0],
+        [1594973151.857818, 0.0],
+        [1594973156.8602881, 0.0],
+        [1594973161.862781, 0.0]
+      ],
+      "op_r": [
+        [1594973071.815675, 0.0],
+        [1594973076.8181818, 0.0],
+        [1594973081.8206801, 0.0],
+        [1594973086.8231986, 0.0],
+        [1594973091.8258255, 0.0],
+        [1594973096.8285067, 0.0],
+        [1594973101.830774, 0.0],
+        [1594973106.8332067, 0.0],
+        [1594973111.8377645, 0.0],
+        [1594973116.8413265, 0.0],
+        [1594973121.8436713, 0.0],
+        [1594973126.846079, 0.0],
+        [1594973131.8485043, 0.0],
+        [1594973136.8509178, 0.0],
+        [1594973141.8532503, 0.0],
+        [1594973146.8557014, 0.0],
+        [1594973151.857818, 0.0],
+        [1594973156.8602881, 0.0],
+        [1594973161.862781, 0.0]
+      ],
+      "op_out_bytes": [
+        [1594973071.815675, 0.0],
+        [1594973076.8181818, 0.0],
+        [1594973081.8206801, 0.0],
+        [1594973086.8231986, 0.0],
+        [1594973091.8258255, 0.0],
+        [1594973096.8285067, 0.0],
+        [1594973101.830774, 0.0],
+        [1594973106.8332067, 0.0],
+        [1594973111.8377645, 0.0],
+        [1594973116.8413265, 0.0],
+        [1594973121.8436713, 0.0],
+        [1594973126.846079, 0.0],
+        [1594973131.8485043, 0.0],
+        [1594973136.8509178, 0.0],
+        [1594973141.8532503, 0.0],
+        [1594973146.8557014, 0.0],
+        [1594973151.857818, 0.0],
+        [1594973156.8602881, 0.0],
+        [1594973161.862781, 0.0]
+      ]
+    }
+  },
+  {
+    "osd": 1,
+    "up": 1,
+    "in": 1,
+    "weight": 1.0,
+    "primary_affinity": 1.0,
+    "last_clean_begin": 0,
+    "last_clean_end": 0,
+    "up_from": 13,
+    "up_thru": 143,
+    "down_at": 0,
+    "lost_at": 0,
+    "public_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 10136, "addr": "192.168.2.106:6810" },
+        { "type": "v1", "nonce": 10136, "addr": "192.168.2.106:6811" }
+      ]
+    },
+    "cluster_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 10136, "addr": "192.168.2.106:6812" },
+        { "type": "v1", "nonce": 10136, "addr": "192.168.2.106:6813" }
+      ]
+    },
+    "heartbeat_back_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 10136, "addr": "192.168.2.106:6816" },
+        { "type": "v1", "nonce": 10136, "addr": "192.168.2.106:6817" }
+      ]
+    },
+    "heartbeat_front_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 10136, "addr": "192.168.2.106:6814" },
+        { "type": "v1", "nonce": 10136, "addr": "192.168.2.106:6815" }
+      ]
+    },
+    "state": ["exists", "up"],
+    "uuid": "b57436ab-31cf-43ab-ae04-2b1ead69d155",
+    "public_addr": "192.168.2.106:6811/10136",
+    "cluster_addr": "192.168.2.106:6813/10136",
+    "heartbeat_back_addr": "192.168.2.106:6817/10136",
+    "heartbeat_front_addr": "192.168.2.106:6815/10136",
+    "id": 1,
+    "osd_stats": {
+      "osd": 1,
+      "up_from": 13,
+      "seq": 55834576483,
+      "num_pgs": 201,
+      "num_osds": 1,
+      "num_per_pool_osds": 1,
+      "num_per_pool_omap_osds": 1,
+      "kb": 105906168,
+      "kb_used": 2099028,
+      "kb_used_data": 1876,
+      "kb_used_omap": 0,
+      "kb_used_meta": 1048576,
+      "kb_avail": 103807140,
+      "statfs": {
+        "total": 108447916032,
+        "available": 106298511360,
+        "internally_reserved": 1073741824,
+        "allocated": 1921024,
+        "data_stored": 748530,
+        "data_compressed": 0,
+        "data_compressed_allocated": 0,
+        "data_compressed_original": 0,
+        "omap_allocated": 0,
+        "internal_metadata": 1073741824
+      },
+      "hb_peers": [0, 2],
+      "snap_trim_queue_len": 0,
+      "num_snap_trimming": 0,
+      "num_shards_repaired": 0,
+      "op_queue_age_hist": { "histogram": [], "upper_bound": 1 },
+      "perf_stat": {
+        "commit_latency_ms": 0.0,
+        "apply_latency_ms": 0.0,
+        "commit_latency_ns": 0,
+        "apply_latency_ns": 0
+      },
+      "alerts": []
+    },
+    "tree": {
+      "id": 1,
+      "device_class": "ssd",
+      "type": "osd",
+      "type_id": 0,
+      "crush_weight": 0.0985870361328125,
+      "depth": 2,
+      "pool_weights": {},
+      "exists": 1,
+      "status": "up",
+      "reweight": 1.0,
+      "primary_affinity": 1.0,
+      "name": "osd.1"
+    },
+    "host": {
+      "id": -3,
+      "name": "ceph-master",
+      "type": "host",
+      "type_id": 1,
+      "pool_weights": {},
+      "children": [2, 1, 0]
+    },
+    "stats": {
+      "op_w": 0.0,
+      "op_in_bytes": 0.0,
+      "op_r": 0.0,
+      "op_out_bytes": 0.0,
+      "numpg": 201,
+      "stat_bytes": 108447916032,
+      "stat_bytes_used": 2149404672
+    },
+    "stats_history": {
+      "op_w": [
+        [1594973072.2473748, 0.0],
+        [1594973077.249638, 0.0],
+        [1594973082.252127, 0.0],
+        [1594973087.2545457, 0.0],
+        [1594973092.2568345, 0.0],
+        [1594973097.2593641, 0.0],
+        [1594973102.2615848, 0.0],
+        [1594973107.263888, 0.0],
+        [1594973112.2665699, 0.0],
+        [1594973117.2689157, 0.0],
+        [1594973122.2711878, 0.0],
+        [1594973127.2736654, 0.0],
+        [1594973132.2760675, 0.0],
+        [1594973137.2787013, 0.0],
+        [1594973142.2811794, 0.0],
+        [1594973147.2834256, 0.0],
+        [1594973152.2856195, 0.0],
+        [1594973157.288044, 0.0],
+        [1594973162.2904015, 0.0]
+      ],
+      "op_in_bytes": [
+        [1594973072.2473748, 0.0],
+        [1594973077.249638, 0.0],
+        [1594973082.252127, 0.0],
+        [1594973087.2545457, 0.0],
+        [1594973092.2568345, 0.0],
+        [1594973097.2593641, 0.0],
+        [1594973102.2615848, 0.0],
+        [1594973107.263888, 0.0],
+        [1594973112.2665699, 0.0],
+        [1594973117.2689157, 0.0],
+        [1594973122.2711878, 0.0],
+        [1594973127.2736654, 0.0],
+        [1594973132.2760675, 0.0],
+        [1594973137.2787013, 0.0],
+        [1594973142.2811794, 0.0],
+        [1594973147.2834256, 0.0],
+        [1594973152.2856195, 0.0],
+        [1594973157.288044, 0.0],
+        [1594973162.2904015, 0.0]
+      ],
+      "op_r": [
+        [1594973072.2473748, 0.0],
+        [1594973077.249638, 0.0],
+        [1594973082.252127, 0.0],
+        [1594973087.2545457, 0.0],
+        [1594973092.2568345, 0.0],
+        [1594973097.2593641, 0.0],
+        [1594973102.2615848, 0.0],
+        [1594973107.263888, 0.0],
+        [1594973112.2665699, 0.0],
+        [1594973117.2689157, 0.0],
+        [1594973122.2711878, 0.0],
+        [1594973127.2736654, 0.0],
+        [1594973132.2760675, 0.0],
+        [1594973137.2787013, 0.0],
+        [1594973142.2811794, 0.0],
+        [1594973147.2834256, 0.0],
+        [1594973152.2856195, 0.0],
+        [1594973157.288044, 0.0],
+        [1594973162.2904015, 0.0]
+      ],
+      "op_out_bytes": [
+        [1594973072.2473748, 0.0],
+        [1594973077.249638, 0.0],
+        [1594973082.252127, 0.0],
+        [1594973087.2545457, 0.0],
+        [1594973092.2568345, 0.0],
+        [1594973097.2593641, 0.0],
+        [1594973102.2615848, 0.0],
+        [1594973107.263888, 0.0],
+        [1594973112.2665699, 0.0],
+        [1594973117.2689157, 0.0],
+        [1594973122.2711878, 0.0],
+        [1594973127.2736654, 0.0],
+        [1594973132.2760675, 0.0],
+        [1594973137.2787013, 0.0],
+        [1594973142.2811794, 0.0],
+        [1594973147.2834256, 0.0],
+        [1594973152.2856195, 0.0],
+        [1594973157.288044, 0.0],
+        [1594973162.2904015, 0.0]
+      ]
+    }
+  },
+  {
+    "osd": 2,
+    "up": 1,
+    "in": 1,
+    "weight": 1.0,
+    "primary_affinity": 1.0,
+    "last_clean_begin": 0,
+    "last_clean_end": 0,
+    "up_from": 17,
+    "up_thru": 143,
+    "down_at": 0,
+    "lost_at": 0,
+    "public_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 11208, "addr": "192.168.2.106:6818" },
+        { "type": "v1", "nonce": 11208, "addr": "192.168.2.106:6819" }
+      ]
+    },
+    "cluster_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 11208, "addr": "192.168.2.106:6820" },
+        { "type": "v1", "nonce": 11208, "addr": "192.168.2.106:6821" }
+      ]
+    },
+    "heartbeat_back_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 11208, "addr": "192.168.2.106:6824" },
+        { "type": "v1", "nonce": 11208, "addr": "192.168.2.106:6825" }
+      ]
+    },
+    "heartbeat_front_addrs": {
+      "addrvec": [
+        { "type": "v2", "nonce": 11208, "addr": "192.168.2.106:6822" },
+        { "type": "v1", "nonce": 11208, "addr": "192.168.2.106:6823" }
+      ]
+    },
+    "state": ["exists", "up"],
+    "uuid": "6e6b88e3-67aa-4ea0-aac0-cbfe89a0f652",
+    "public_addr": "192.168.2.106:6819/11208",
+    "cluster_addr": "192.168.2.106:6821/11208",
+    "heartbeat_back_addr": "192.168.2.106:6825/11208",
+    "heartbeat_front_addr": "192.168.2.106:6823/11208",
+    "id": 2,
+    "osd_stats": {
+      "osd": 2,
+      "up_from": 17,
+      "seq": 73014445666,
+      "num_pgs": 201,
+      "num_osds": 1,
+      "num_per_pool_osds": 1,
+      "num_per_pool_omap_osds": 1,
+      "kb": 105906168,
+      "kb_used": 2099028,
+      "kb_used_data": 1876,
+      "kb_used_omap": 0,
+      "kb_used_meta": 1048576,
+      "kb_avail": 103807140,
+      "statfs": {
+        "total": 108447916032,
+        "available": 106298511360,
+        "internally_reserved": 1073741824,
+        "allocated": 1921024,
+        "data_stored": 748530,
+        "data_compressed": 0,
+        "data_compressed_allocated": 0,
+        "data_compressed_original": 0,
+        "omap_allocated": 0,
+        "internal_metadata": 1073741824
+      },
+      "hb_peers": [0, 1],
+      "snap_trim_queue_len": 0,
+      "num_snap_trimming": 0,
+      "num_shards_repaired": 0,
+      "op_queue_age_hist": { "histogram": [], "upper_bound": 1 },
+      "perf_stat": {
+        "commit_latency_ms": 0.0,
+        "apply_latency_ms": 0.0,
+        "commit_latency_ns": 0,
+        "apply_latency_ns": 0
+      },
+      "alerts": []
+    },
+    "tree": {
+      "id": 2,
+      "device_class": "ssd",
+      "type": "osd",
+      "type_id": 0,
+      "crush_weight": 0.0985870361328125,
+      "depth": 2,
+      "pool_weights": {},
+      "exists": 1,
+      "status": "up",
+      "reweight": 1.0,
+      "primary_affinity": 1.0,
+      "name": "osd.2"
+    },
+    "host": {
+      "id": -3,
+      "name": "ceph-master",
+      "type": "host",
+      "type_id": 1,
+      "pool_weights": {},
+      "children": [2, 1, 0]
+    },
+    "stats": {
+      "op_w": 0.0,
+      "op_in_bytes": 0.0,
+      "op_r": 0.0,
+      "op_out_bytes": 0.0,
+      "numpg": 201,
+      "stat_bytes": 108447916032,
+      "stat_bytes_used": 2149404672
+    },
+    "stats_history": {
+      "op_w": [
+        [1594973071.7967167, 0.0],
+        [1594973076.7992308, 0.0],
+        [1594973081.8016157, 0.0],
+        [1594973086.8038485, 0.0],
+        [1594973091.806146, 0.0],
+        [1594973096.8079553, 0.0],
+        [1594973101.8099923, 0.0],
+        [1594973106.8122191, 0.0],
+        [1594973111.814509, 0.0],
+        [1594973116.8168204, 0.0],
+        [1594973121.8191206, 0.0],
+        [1594973126.8215034, 0.0],
+        [1594973131.8238406, 0.0],
+        [1594973136.8261213, 0.0],
+        [1594973141.8283849, 0.0],
+        [1594973146.8305933, 0.0],
+        [1594973151.8342226, 0.0],
+        [1594973156.837437, 0.0],
+        [1594973161.8397536, 0.0]
+      ],
+      "op_in_bytes": [
+        [1594973071.7967167, 0.0],
+        [1594973076.7992308, 0.0],
+        [1594973081.8016157, 0.0],
+        [1594973086.8038485, 0.0],
+        [1594973091.806146, 0.0],
+        [1594973096.8079553, 0.0],
+        [1594973101.8099923, 0.0],
+        [1594973106.8122191, 0.0],
+        [1594973111.814509, 0.0],
+        [1594973116.8168204, 0.0],
+        [1594973121.8191206, 0.0],
+        [1594973126.8215034, 0.0],
+        [1594973131.8238406, 0.0],
+        [1594973136.8261213, 0.0],
+        [1594973141.8283849, 0.0],
+        [1594973146.8305933, 0.0],
+        [1594973151.8342226, 0.0],
+        [1594973156.837437, 0.0],
+        [1594973161.8397536, 0.0]
+      ],
+      "op_r": [
+        [1594973071.7967167, 0.0],
+        [1594973076.7992308, 0.0],
+        [1594973081.8016157, 0.0],
+        [1594973086.8038485, 0.0],
+        [1594973091.806146, 0.0],
+        [1594973096.8079553, 0.0],
+        [1594973101.8099923, 0.0],
+        [1594973106.8122191, 0.0],
+        [1594973111.814509, 0.0],
+        [1594973116.8168204, 0.0],
+        [1594973121.8191206, 0.0],
+        [1594973126.8215034, 0.0],
+        [1594973131.8238406, 0.0],
+        [1594973136.8261213, 0.0],
+        [1594973141.8283849, 0.0],
+        [1594973146.8305933, 0.0],
+        [1594973151.8342226, 0.0],
+        [1594973156.837437, 0.0],
+        [1594973161.8397536, 0.0]
+      ],
+      "op_out_bytes": [
+        [1594973071.7967167, 0.0],
+        [1594973076.7992308, 0.0],
+        [1594973081.8016157, 0.0],
+        [1594973086.8038485, 0.0],
+        [1594973091.806146, 0.0],
+        [1594973096.8079553, 0.0],
+        [1594973101.8099923, 0.0],
+        [1594973106.8122191, 0.0],
+        [1594973111.814509, 0.0],
+        [1594973116.8168204, 0.0],
+        [1594973121.8191206, 0.0],
+        [1594973126.8215034, 0.0],
+        [1594973131.8238406, 0.0],
+        [1594973136.8261213, 0.0],
+        [1594973141.8283849, 0.0],
+        [1594973146.8305933, 0.0],
+        [1594973151.8342226, 0.0],
+        [1594973156.837437, 0.0],
+        [1594973161.8397536, 0.0]
+      ]
+    }
+  }
+]
index f928e4de4b06070c6546bdbfd2a2d34f82de2cc6..52ccec23622fa1fcc72220e7385d831e75d343ae 100644 (file)
@@ -10,7 +10,12 @@ import * as _ from 'lodash';
 import { ToastrModule } from 'ngx-toastr';
 import { EMPTY, of } from 'rxjs';
 
-import { configureTestBed, PermissionHelper } from '../../../../../testing/unit-test-helper';
+import {
+  configureTestBed,
+  OrchestratorHelper,
+  PermissionHelper,
+  TableActionHelper
+} from '../../../../../testing/unit-test-helper';
 import { CoreModule } from '../../../../core/core.module';
 import { OrchestratorService } from '../../../../shared/api/orchestrator.service';
 import { OsdService } from '../../../../shared/api/osd.service';
@@ -20,6 +25,7 @@ import { FormModalComponent } from '../../../../shared/components/form-modal/for
 import { TableActionsComponent } from '../../../../shared/datatable/table-actions/table-actions.component';
 import { CdTableAction } from '../../../../shared/models/cd-table-action';
 import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
+import { OrchestratorFeature } from '../../../../shared/models/orchestrator.enum';
 import { Permissions } from '../../../../shared/models/permissions';
 import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
 import { ModalService } from '../../../../shared/services/modal.service';
@@ -33,6 +39,7 @@ describe('OsdListComponent', () => {
   let fixture: ComponentFixture<OsdListComponent>;
   let modalServiceShowSpy: jasmine.Spy;
   let osdService: OsdService;
+  let orchService: OrchestratorService;
 
   const fakeAuthStorageService = {
     getPermissions: () => {
@@ -80,10 +87,9 @@ describe('OsdListComponent', () => {
     );
   };
 
-  const mockOrchestratorStatus = () => {
-    spyOn(TestBed.inject(OrchestratorService), 'status').and.callFake(() =>
-      of({ available: true })
-    );
+  const mockOrch = () => {
+    const features = [OrchestratorFeature.OSD_CREATE, OrchestratorFeature.OSD_DELETE];
+    OrchestratorHelper.mockStatus(true, features);
   };
 
   configureTestBed({
@@ -110,7 +116,11 @@ describe('OsdListComponent', () => {
     fixture = TestBed.createComponent(OsdListComponent);
     component = fixture.componentInstance;
     osdService = TestBed.inject(OsdService);
-    modalServiceShowSpy = spyOn(TestBed.inject(ModalService), 'show').and.stub();
+    modalServiceShowSpy = spyOn(TestBed.inject(ModalService), 'show').and.returnValue({
+      // mock the close function, it might be called if there are async tests.
+      close: jest.fn()
+    });
+    orchService = TestBed.inject(OrchestratorService);
   });
 
   it('should create', () => {
@@ -404,7 +414,7 @@ describe('OsdListComponent', () => {
       expectOpensModal('Mark Lost', modalClass);
       expectOpensModal('Purge', modalClass);
       expectOpensModal('Destroy', modalClass);
-      mockOrchestratorStatus();
+      mockOrch();
       mockSafeToDelete();
       expectOpensModal('Delete', modalClass);
     });
@@ -447,9 +457,111 @@ describe('OsdListComponent', () => {
       expectOsdServiceMethodCalled('Mark Lost', 'markLost');
       expectOsdServiceMethodCalled('Purge', 'purge');
       expectOsdServiceMethodCalled('Destroy', 'destroy');
-      mockOrchestratorStatus();
+      mockOrch();
       mockSafeToDelete();
       expectOsdServiceMethodCalled('Delete', 'delete');
     });
   });
+
+  describe('table actions', () => {
+    const fakeOsds = require('./fixtures/osd_list_response.json');
+
+    beforeEach(() => {
+      component.permissions = fakeAuthStorageService.getPermissions();
+      spyOn(osdService, 'getList').and.callFake(() => of(fakeOsds));
+    });
+
+    const testTableActions = async (
+      orch: boolean,
+      features: OrchestratorFeature[],
+      tests: { selectRow?: number; expectResults: any }[]
+    ) => {
+      OrchestratorHelper.mockStatus(orch, features);
+      fixture.detectChanges();
+      await fixture.whenStable();
+
+      for (const test of tests) {
+        if (test.selectRow) {
+          component.selection = new CdTableSelection();
+          component.selection.selected = [test.selectRow];
+        }
+        await TableActionHelper.verifyTableActions(
+          fixture,
+          component.tableActions,
+          test.expectResults
+        );
+      }
+    };
+
+    it('should have correct states when Orchestrator is enabled', async () => {
+      const tests = [
+        {
+          expectResults: {
+            Create: { disabled: false, disableDesc: '' },
+            Delete: { disabled: true, disableDesc: '' }
+          }
+        },
+        {
+          selectRow: fakeOsds[0],
+          expectResults: {
+            Create: { disabled: false, disableDesc: '' },
+            Delete: { disabled: false, disableDesc: '' }
+          }
+        }
+      ];
+
+      const features = [
+        OrchestratorFeature.OSD_CREATE,
+        OrchestratorFeature.OSD_DELETE,
+        OrchestratorFeature.OSD_GET_REMOVE_STATUS
+      ];
+      await testTableActions(true, features, tests);
+    });
+
+    it('should have correct states when Orchestrator is disabled', async () => {
+      const resultNoOrchestrator = {
+        disabled: true,
+        disableDesc: orchService.disableMessages.noOrchestrator
+      };
+      const tests = [
+        {
+          expectResults: {
+            Create: resultNoOrchestrator,
+            Delete: { disabled: true, disableDesc: '' }
+          }
+        },
+        {
+          selectRow: fakeOsds[0],
+          expectResults: {
+            Create: resultNoOrchestrator,
+            Delete: resultNoOrchestrator
+          }
+        }
+      ];
+      await testTableActions(false, [], tests);
+    });
+
+    it('should have correct states when Orchestrator features are missing', async () => {
+      const resultMissingFeatures = {
+        disabled: true,
+        disableDesc: orchService.disableMessages.missingFeature
+      };
+      const tests = [
+        {
+          expectResults: {
+            Create: resultMissingFeatures,
+            Delete: { disabled: true, disableDesc: '' }
+          }
+        },
+        {
+          selectRow: fakeOsds[0],
+          expectResults: {
+            Create: resultMissingFeatures,
+            Delete: resultMissingFeatures
+          }
+        }
+      ];
+      await testTableActions(true, [], tests);
+    });
+  });
 });
index 96c38fff94b4bb85b22df1a9c31041ce4d979642..ef9483aab25c0e1451c3e56c554b04e7a86fc5a3 100644 (file)
@@ -6,6 +6,7 @@ import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
 import * as _ from 'lodash';
 import { forkJoin as observableForkJoin, Observable } from 'rxjs';
 
+import { OrchestratorService } from '../../../../shared/api/orchestrator.service';
 import { OsdService } from '../../../../shared/api/osd.service';
 import { ListWithDetails } from '../../../../shared/classes/list-with-details.class';
 import { ConfirmationModalComponent } from '../../../../shared/components/confirmation-modal/confirmation-modal.component';
@@ -20,10 +21,11 @@ import { CdTableAction } from '../../../../shared/models/cd-table-action';
 import { CdTableColumn } from '../../../../shared/models/cd-table-column';
 import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
 import { FinishedTask } from '../../../../shared/models/finished-task';
+import { OrchestratorFeature } from '../../../../shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '../../../../shared/models/orchestrator.interface';
 import { Permissions } from '../../../../shared/models/permissions';
 import { DimlessBinaryPipe } from '../../../../shared/pipes/dimless-binary.pipe';
 import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
-import { DepCheckerService } from '../../../../shared/services/dep-checker.service';
 import { ModalService } from '../../../../shared/services/modal.service';
 import { NotificationService } from '../../../../shared/services/notification.service';
 import { TaskWrapperService } from '../../../../shared/services/task-wrapper.service';
@@ -66,6 +68,12 @@ export class OsdListComponent extends ListWithDetails implements OnInit {
   selection = new CdTableSelection();
   osds: any[] = [];
 
+  orchStatus: OrchestratorStatus;
+  actionOrchFeatures = {
+    create: [OrchestratorFeature.OSD_CREATE],
+    delete: [OrchestratorFeature.OSD_DELETE]
+  };
+
   protected static collectStates(osd: any) {
     const states = [osd['in'] ? 'in' : 'out'];
     if (osd['up']) {
@@ -85,10 +93,10 @@ export class OsdListComponent extends ListWithDetails implements OnInit {
     private modalService: ModalService,
     private urlBuilder: URLBuilderService,
     private router: Router,
-    private depCheckerService: DepCheckerService,
     private taskWrapper: TaskWrapperService,
     public actionLabels: ActionLabelsI18n,
-    public notificationService: NotificationService
+    public notificationService: NotificationService,
+    private orchService: OrchestratorService
   ) {
     super();
     this.permissions = this.authStorageService.getPermissions();
@@ -97,15 +105,8 @@ export class OsdListComponent extends ListWithDetails implements OnInit {
         name: this.actionLabels.CREATE,
         permission: 'create',
         icon: Icons.add,
-        click: () => {
-          this.depCheckerService.checkOrchestratorOrModal(
-            this.actionLabels.CREATE,
-            $localize`OSD`,
-            () => {
-              this.router.navigate([this.urlBuilder.getCreate()]);
-            }
-          );
-        },
+        click: () => this.router.navigate([this.urlBuilder.getCreate()]),
+        disable: (selection: CdTableSelection) => this.getDisable('create', selection),
         canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
       },
       {
@@ -218,7 +219,7 @@ export class OsdListComponent extends ListWithDetails implements OnInit {
         name: this.actionLabels.DELETE,
         permission: 'delete',
         click: () => this.delete(),
-        disable: () => !this.hasOsdSelected,
+        disable: (selection: CdTableSelection) => this.getDisable('delete', selection),
         icon: Icons.destroy
       }
     ];
@@ -311,6 +312,18 @@ export class OsdListComponent extends ListWithDetails implements OnInit {
         cellTransformation: CellTemplate.perSecond
       }
     ];
+
+    this.orchService.status().subscribe((status: OrchestratorStatus) => (this.orchStatus = status));
+  }
+
+  getDisable(action: 'create' | 'delete', selection: CdTableSelection): boolean | string {
+    if (action === 'delete' && !selection.hasSelection) {
+      return true;
+    }
+    return this.orchService.getTableActionDisableDesc(
+      this.orchStatus,
+      this.actionOrchFeatures[action]
+    );
   }
 
   /**
@@ -443,32 +456,26 @@ export class OsdListComponent extends ListWithDetails implements OnInit {
       preserve: new FormControl(false)
     });
 
-    this.depCheckerService.checkOrchestratorOrModal(
-      this.actionLabels.DELETE,
+    this.showCriticalConfirmationModal(
+      $localize`delete`,
       $localize`OSD`,
-      () => {
-        this.showCriticalConfirmationModal(
-          $localize`delete`,
-          $localize`OSD`,
-          $localize`deleted`,
-          (ids: number[]) => {
-            return this.osdService.safeToDelete(JSON.stringify(ids));
-          },
-          'is_safe_to_delete',
-          (id: number) => {
-            this.selection = new CdTableSelection();
-            return this.taskWrapper.wrapTaskAroundCall({
-              task: new FinishedTask('osd/' + URLVerbs.DELETE, {
-                svc_id: id
-              }),
-              call: this.osdService.delete(id, deleteFormGroup.value.preserve, true)
-            });
-          },
-          true,
-          deleteFormGroup,
-          this.deleteOsdExtraTpl
-        );
-      }
+      $localize`deleted`,
+      (ids: number[]) => {
+        return this.osdService.safeToDelete(JSON.stringify(ids));
+      },
+      'is_safe_to_delete',
+      (id: number) => {
+        this.selection = new CdTableSelection();
+        return this.taskWrapper.wrapTaskAroundCall({
+          task: new FinishedTask('osd/' + URLVerbs.DELETE, {
+            svc_id: id
+          }),
+          call: this.osdService.delete(id, deleteFormGroup.value.preserve, true)
+        });
+      },
+      true,
+      deleteFormGroup,
+      this.deleteOsdExtraTpl
     );
   }
 
index 64ec411aca6392deea81789b3dc745e616f9e6f9..1db551591f5d5e6a345bfda850b647b84f885dbe 100644 (file)
@@ -1,5 +1,5 @@
-<cd-orchestrator-doc-panel *ngIf="!hasOrchestrator"></cd-orchestrator-doc-panel>
-<ng-container *ngIf="hasOrchestrator">
+<cd-orchestrator-doc-panel *ngIf="!orchStatus?.available"></cd-orchestrator-doc-panel>
+<ng-container *ngIf="orchStatus?.available">
   <cd-table [data]="services"
             [columns]="columns"
             identifier="service_name"
index f568b370fed97ee329ea980c79bef9e82894789a..b92e39bb31bc99e226378a3b33e2009c1a23d02b 100644 (file)
@@ -15,6 +15,8 @@ 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 { OrchestratorFeature } from '../../../shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '../../../shared/models/orchestrator.interface';
 import { Permissions } from '../../../shared/models/permissions';
 import { CephServiceSpec } from '../../../shared/models/service.interface';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
@@ -42,8 +44,11 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
   permissions: Permissions;
   tableActions: CdTableAction[];
 
-  checkingOrchestrator = true;
-  hasOrchestrator = false;
+  orchStatus: OrchestratorStatus;
+  actionOrchFeatures = {
+    create: [OrchestratorFeature.SERVICE_CREATE],
+    delete: [OrchestratorFeature.SERVICE_DELETE]
+  };
 
   columns: Array<CdTableColumn> = [];
   services: Array<CephServiceSpec> = [];
@@ -67,14 +72,15 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
         icon: Icons.add,
         routerLink: () => this.urlBuilder.getCreate(),
         name: this.actionLabels.CREATE,
-        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+        disable: (selection: CdTableSelection) => this.getDisable('create', selection)
       },
       {
         permission: 'delete',
         icon: Icons.destroy,
         click: () => this.deleteAction(),
-        disable: () => !this.selection.hasSingleSelection,
-        name: this.actionLabels.DELETE
+        name: this.actionLabels.DELETE,
+        disable: (selection: CdTableSelection) => this.getDisable('delete', selection)
       }
     ];
   }
@@ -121,18 +127,30 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
       return !this.hiddenColumns.includes(col.prop);
     });
 
-    this.orchService.status().subscribe((status) => {
-      this.hasOrchestrator = status.available;
+    this.orchService.status().subscribe((status: OrchestratorStatus) => {
+      this.orchStatus = status;
     });
   }
 
   ngOnChanges() {
-    if (this.hasOrchestrator) {
+    if (this.orchStatus?.available) {
       this.services = [];
       this.table.reloadData();
     }
   }
 
+  getDisable(action: 'create' | 'delete', selection: CdTableSelection): boolean | string {
+    if (action === 'delete') {
+      if (!selection?.hasSingleSelection) {
+        return true;
+      }
+    }
+    return this.orchService.getTableActionDisableDesc(
+      this.orchStatus,
+      this.actionOrchFeatures[action]
+    );
+  }
+
   getServices(context: CdTableFetchDataContext) {
     if (this.isLoadingServices) {
       return;
index df05ff9c695007a11e035bf61cb20960d8b16f3d..0fe9883f9e3da2764773065b2528085eadd43ede 100644 (file)
@@ -7,6 +7,8 @@ import { mergeMap } from 'rxjs/operators';
 
 import { InventoryDevice } from '../../ceph/cluster/inventory/inventory-devices/inventory-device.model';
 import { InventoryHost } from '../../ceph/cluster/inventory/inventory-host.model';
+import { OrchestratorFeature } from '../models/orchestrator.enum';
+import { OrchestratorStatus } from '../models/orchestrator.interface';
 
 @Injectable({
   providedIn: 'root'
@@ -14,10 +16,35 @@ import { InventoryHost } from '../../ceph/cluster/inventory/inventory-host.model
 export class OrchestratorService {
   private url = 'api/orchestrator';
 
+  disableMessages = {
+    noOrchestrator: $localize`The feature is disabled because Orchestrator is not available.`,
+    missingFeature: $localize`The Orchestrator backend doesn't support this feature.`
+  };
+
   constructor(private http: HttpClient) {}
 
-  status(): Observable<{ available: boolean; description: string }> {
-    return this.http.get<{ available: boolean; description: string }>(`${this.url}/status`);
+  status(): Observable<OrchestratorStatus> {
+    return this.http.get<OrchestratorStatus>(`${this.url}/status`);
+  }
+
+  hasFeature(status: OrchestratorStatus, features: OrchestratorFeature[]): boolean {
+    return _.every(features, (feature) => _.get(status.features, `${feature}.available`));
+  }
+
+  getTableActionDisableDesc(
+    status: OrchestratorStatus,
+    features: OrchestratorFeature[]
+  ): boolean | string {
+    if (!status) {
+      return false;
+    }
+    if (!status.available) {
+      return this.disableMessages.noOrchestrator;
+    }
+    if (!this.hasFeature(status, features)) {
+      return this.disableMessages.missingFeature;
+    }
+    return false;
   }
 
   identifyDevice(hostname: string, device: string, duration: number) {
index 52938b6ad10916156ba72a190fbb43ffd6720c19..95cc5ae0e3908cb47708bdb38e3fc3b8b6360685 100644 (file)
@@ -33,7 +33,6 @@ import { LanguageSelectorComponent } from './language-selector/language-selector
 import { LoadingPanelComponent } from './loading-panel/loading-panel.component';
 import { ModalComponent } from './modal/modal.component';
 import { NotificationsSidebarComponent } from './notifications-sidebar/notifications-sidebar.component';
-import { OrchestratorDocModalComponent } from './orchestrator-doc-modal/orchestrator-doc-modal.component';
 import { OrchestratorDocPanelComponent } from './orchestrator-doc-panel/orchestrator-doc-panel.component';
 import { PwdExpirationNotificationComponent } from './pwd-expiration-notification/pwd-expiration-notification.component';
 import { RefreshSelectorComponent } from './refresh-selector/refresh-selector.component';
@@ -87,7 +86,6 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component';
     PwdExpirationNotificationComponent,
     TelemetryNotificationComponent,
     OrchestratorDocPanelComponent,
-    OrchestratorDocModalComponent,
     DateTimePickerComponent,
     DocComponent
   ],
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.html
deleted file mode 100644 (file)
index 338be7d..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<cd-modal [modalRef]="activeModal">
-  <ng-container class="modal-title"
-                i18n>{{ actionDescription }} {{ itemDescription }}</ng-container>
-
-  <ng-container class="modal-content">
-    <div class="modal-body">
-      <cd-orchestrator-doc-panel></cd-orchestrator-doc-panel>
-    </div>
-    <div class="modal-footer">
-      <cd-back-button [back]="activeModal.close"
-                      name="Close"
-                      i18n-name>
-      </cd-back-button>
-    </div>
-  </ng-container>
-</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.spec.ts
deleted file mode 100644 (file)
index ef4ed3e..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { RouterTestingModule } from '@angular/router/testing';
-
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
-
-import { configureTestBed } from '../../../../testing/unit-test-helper';
-import { ComponentsModule } from '../components.module';
-import { OrchestratorDocModalComponent } from './orchestrator-doc-modal.component';
-
-describe('OrchestratorDocModalComponent', () => {
-  let component: OrchestratorDocModalComponent;
-  let fixture: ComponentFixture<OrchestratorDocModalComponent>;
-
-  configureTestBed({
-    imports: [ComponentsModule, HttpClientTestingModule, RouterTestingModule],
-    providers: [NgbActiveModal]
-  });
-
-  beforeEach(() => {
-    fixture = TestBed.createComponent(OrchestratorDocModalComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.ts
deleted file mode 100644 (file)
index d8fd210..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Component } from '@angular/core';
-
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
-
-@Component({
-  selector: 'cd-orchestrator-doc-modal',
-  templateUrl: './orchestrator-doc-modal.component.html',
-  styleUrls: ['./orchestrator-doc-modal.component.scss']
-})
-export class OrchestratorDocModalComponent {
-  actionDescription: string;
-  itemDescription: string;
-
-  constructor(public activeModal: NgbActiveModal) {}
-
-  onSubmit() {
-    this.activeModal.close();
-  }
-}
index 4c7175910b068360a9733c5edcd903b2d93c7c2d..f33261d8019c224ad27edfb5599684f6355eee5f 100644 (file)
@@ -1,4 +1,10 @@
-<cd-alert-panel type="info"
-                i18n>Orchestrator is not available.
-  Please consult the <cd-doc section="orch"></cd-doc> on how to configure and
-  enable the functionality.</cd-alert-panel>
+<cd-alert-panel *ngIf="missingFeatures; else elseBlock"
+                type="info"
+                i18n>The feature is not supported in the current Orchestrator.</cd-alert-panel>
+
+<ng-template #elseBlock>
+  <cd-alert-panel type="info"
+                  i18n>Orchestrator is not available.
+    Please consult the <cd-doc section="orch"></cd-doc> on how to configure and
+    enable the functionality.</cd-alert-panel>
+</ng-template>
index 71c94ec7facdc75b569edec5e1547145bfe32a80..946f7efa88a8ce9bbd4aabe1636f6c026e07f74b 100644 (file)
@@ -1,8 +1,13 @@
-import { Component } from '@angular/core';
+import { Component, Input } from '@angular/core';
+
+import { OrchestratorFeature } from '../../models/orchestrator.enum';
 
 @Component({
   selector: 'cd-orchestrator-doc-panel',
   templateUrl: './orchestrator-doc-panel.component.html',
   styleUrls: ['./orchestrator-doc-panel.component.scss']
 })
-export class OrchestratorDocPanelComponent {}
+export class OrchestratorDocPanelComponent {
+  @Input()
+  missingFeatures: OrchestratorFeature[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts
new file mode 100644 (file)
index 0000000..50a70df
--- /dev/null
@@ -0,0 +1,20 @@
+export enum OrchestratorFeature {
+  HOST_LIST = 'get_hosts',
+  HOST_CREATE = 'add_host',
+  HOST_DELETE = 'remove_host',
+  HOST_LABEL_ADD = 'add_host_label',
+  HOST_LABEL_REMOVE = 'remove_host_label',
+
+  SERVICE_LIST = 'describe_service',
+  SERVICE_CREATE = 'apply',
+  SERVICE_DELETE = 'remove_service',
+  SERVICE_RELOAD = 'service_action',
+  DAEMON_LIST = 'list_daemons',
+
+  OSD_GET_REMOVE_STATUS = 'remove_osds_status',
+  OSD_CREATE = 'apply_drivegroups',
+  OSD_DELETE = 'remove_osds',
+
+  DEVICE_LIST = 'get_inventory',
+  DEVICE_BLINK_LIGHT = 'blink_device_light'
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts
new file mode 100644 (file)
index 0000000..feed4a8
--- /dev/null
@@ -0,0 +1,9 @@
+export interface OrchestratorStatus {
+  available: boolean;
+  description: string;
+  features: {
+    [feature: string]: {
+      available: boolean;
+    };
+  };
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/dep-checker.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/dep-checker.service.spec.ts
deleted file mode 100644 (file)
index 1ae3095..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { TestBed } from '@angular/core/testing';
-
-import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
-
-import { configureTestBed } from '../../../testing/unit-test-helper';
-import { OrchestratorService } from '../api/orchestrator.service';
-import { DepCheckerService } from './dep-checker.service';
-
-describe('DepCheckerService', () => {
-  configureTestBed({
-    providers: [DepCheckerService, OrchestratorService],
-    imports: [HttpClientTestingModule, NgbModalModule]
-  });
-
-  it('should be created', () => {
-    const service: DepCheckerService = TestBed.inject(DepCheckerService);
-    expect(service).toBeTruthy();
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/dep-checker.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/dep-checker.service.ts
deleted file mode 100644 (file)
index 95cf292..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Injectable } from '@angular/core';
-
-import { OrchestratorService } from '../api/orchestrator.service';
-import { OrchestratorDocModalComponent } from '../components/orchestrator-doc-modal/orchestrator-doc-modal.component';
-import { ModalService } from './modal.service';
-
-@Injectable({
-  providedIn: 'root'
-})
-export class DepCheckerService {
-  constructor(private orchService: OrchestratorService, private modalService: ModalService) {}
-
-  /**
-   * Check if orchestrator is available. Display an information modal if not.
-   * If orchestrator is available, then the provided function will be called.
-   * This helper function can be used with table actions.
-   * @param {string} actionDescription name of the action.
-   * @param {string} itemDescription the item's name that the action operates on.
-   * @param {Function} func the function to be called if orchestrator is available.
-   */
-  checkOrchestratorOrModal(actionDescription: string, itemDescription: string, func: Function) {
-    this.orchService.status().subscribe((status) => {
-      if (status.available) {
-        func();
-      } else {
-        this.modalService.show(OrchestratorDocModalComponent, {
-          actionDescription: actionDescription,
-          itemDescription: itemDescription
-        });
-      }
-    });
-  }
-}
index c260a6fe0c1e5fdbe2b2df943e362bf70aa236d3..c31d70eadd8c6376581433416a2959fc9cfd4840 100644 (file)
@@ -6,8 +6,10 @@ import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/t
 
 import { NgbModal, NgbNav, NgbNavItem } from '@ng-bootstrap/ng-bootstrap';
 import { configureTestSuite } from 'ng-bullet';
+import { of } from 'rxjs';
 
 import { InventoryDevice } from '../app/ceph/cluster/inventory/inventory-devices/inventory-device.model';
+import { OrchestratorService } from '../app/shared/api/orchestrator.service';
 import { TableActionsComponent } from '../app/shared/datatable/table-actions/table-actions.component';
 import { Icons } from '../app/shared/enum/icons.enum';
 import { CdFormGroup } from '../app/shared/forms/cd-form-group';
@@ -15,6 +17,7 @@ import { CdTableAction } from '../app/shared/models/cd-table-action';
 import { CdTableSelection } from '../app/shared/models/cd-table-selection';
 import { CrushNode } from '../app/shared/models/crush-node';
 import { CrushRule, CrushRuleConfig } from '../app/shared/models/crush-rule';
+import { OrchestratorFeature } from '../app/shared/models/orchestrator.enum';
 import { Permission } from '../app/shared/models/permissions';
 import {
   AlertmanagerAlert,
@@ -579,3 +582,61 @@ export class TabHelper {
     return debugElem.queryAll(By.directive(NgbNavItem));
   }
 }
+
+export class OrchestratorHelper {
+  /**
+   * Mock Orchestrator status.
+   * @param available is the Orchestrator enabled?
+   * @param features A list of enabled Orchestrator features.
+   */
+  static mockStatus(available: boolean, features?: OrchestratorFeature[]) {
+    const orchStatus = { available: available, description: '', features: {} };
+    if (features) {
+      features.forEach((feature: OrchestratorFeature) => {
+        orchStatus.features[feature] = { available: true };
+      });
+    }
+    spyOn(TestBed.inject(OrchestratorService), 'status').and.callFake(() => of(orchStatus));
+  }
+}
+
+export class TableActionHelper {
+  /**
+   * Verify table action buttons, including the button disabled state and disable description.
+   *
+   * @param fixture  test fixture
+   * @param tableActions table actions
+   * @param expectResult expected values. e.g. {Create: { disabled: true, disableDesc: 'not supported'}}.
+   *                     Expect the Create button to be disabled with 'not supported' tooltip.
+   */
+  static verifyTableActions = async (
+    fixture: ComponentFixture<any>,
+    tableActions: CdTableAction[],
+    expectResult: {
+      [action: string]: { disabled: boolean; disableDesc: string };
+    }
+  ) => {
+    // click dropdown to update all actions buttons
+    const dropDownToggle = fixture.debugElement.query(By.css('.dropdown-toggle'));
+    dropDownToggle.triggerEventHandler('click', null);
+    fixture.detectChanges();
+    await fixture.whenStable();
+
+    const tableActionElement = fixture.debugElement.query(By.directive(TableActionsComponent));
+    const toClassName = TestBed.inject(TableActionsComponent).toClassName;
+    const getActionElement = (action: CdTableAction) =>
+      tableActionElement.query(By.css(`[ngbDropdownItem].${toClassName(action.name)}`));
+
+    const actions = {};
+    tableActions.forEach((action) => {
+      const actionElement = getActionElement(action);
+      if (expectResult[action.name]) {
+        actions[action.name] = {
+          disabled: actionElement.classes.disabled,
+          disableDesc: actionElement.properties.title
+        };
+      }
+    });
+    expect(actions).toEqual(expectResult);
+  };
+}
index e9144db406eddff2234b3bcd8adabb63690dbf71..c5fc4aa63eee5ebec519f7279ecc89a00181167f 100644 (file)
@@ -147,7 +147,7 @@ class Ganesha(object):
                 continue
             if daemons[cluster_id][daemon_id] == 1:
                 reload_list.append((cluster_id, daemon_id))
-        OrchClient.instance().reload_service("nfs", reload_list)
+        OrchClient.instance().services.reload("nfs", reload_list)
 
     @classmethod
     def fsals_available(cls):
index 282674ee159646baf886f1af0143836273efeb0c..7841bf519835d529ab6fa537c9806d120906decc 100644 (file)
@@ -3,7 +3,7 @@ from __future__ import absolute_import
 import logging
 
 from functools import wraps
-from typing import List, Optional, Dict
+from typing import List, Optional, Dict, Any
 
 from ceph.deployment.service_spec import ServiceSpec
 from orchestrator import InventoryFilter, DeviceLightLoc, Completion
@@ -141,6 +141,7 @@ class OrchClient(object):
 
     @classmethod
     def instance(cls):
+        # type: () -> OrchClient
         if cls._instance is None:
             cls._instance = cls()
         return cls._instance
@@ -153,14 +154,47 @@ class OrchClient(object):
         self.services = ServiceManager(self.api)
         self.osds = OsdManager(self.api)
 
-    def available(self):
-        return self.status()['available']
+    def available(self, features: Optional[List[str]] = None) -> bool:
+        available = self.status()['available']
+        if available and features is not None:
+            return not self.get_missing_features(features)
+        return available
 
-    def status(self):
-        return self.api.status()
+    def status(self) -> Dict[str, Any]:
+        status = self.api.status()
+        status['features'] = {}
+        if status['available']:
+            status['features'] = self.api.get_feature_set()
+        return status
+
+    def get_missing_features(self, features: List[str]) -> List[str]:
+        supported_features = {k for k, v in self.api.get_feature_set().items() if v['available']}
+        return list(set(features) - supported_features)
 
     @wait_api_result
     def blink_device_light(self, hostname, device, ident_fault, on):
         # type: (str, str, str, bool) -> Completion
         return self.api.blink_device_light(
             ident_fault, on, [DeviceLightLoc(hostname, device, device)])
+
+
+class OrchFeature(object):
+    HOST_LIST = 'get_hosts'
+    HOST_CREATE = 'add_host'
+    HOST_DELETE = 'remove_host'
+    HOST_LABEL_ADD = 'add_host_label'
+    HOST_LABEL_REMOVE = 'remove_host_label'
+
+    SERVICE_LIST = 'describe_service'
+    SERVICE_CREATE = 'apply'
+    SERVICE_DELETE = 'remove_service'
+    SERVICE_RELOAD = 'service_action'
+    DAEMON_LIST = 'list_daemons'
+
+    OSD_GET_REMOVE_STATUS = 'remove_osds_status'
+
+    OSD_CREATE = 'apply_drivegroups'
+    OSD_DELETE = 'remove_osds'
+
+    DEVICE_LIST = 'get_inventory'
+    DEVICE_BLINK_LIGHT = 'blink_device_light'
index ab7286074b7386b53adb88a99756a2ffdc8e3c31..054d6559aac5bfe8f6da7247b0b4c234dc616f30 100644 (file)
@@ -112,6 +112,7 @@ class HostControllerTest(ControllerTestCase):
 
         fake_client = mock.Mock()
         fake_client.available.return_value = True
+        fake_client.get_missing_features.return_value = []
         fake_client.hosts.list.return_value = [
             HostSpec('node0', labels=['aaa', 'bbb'])
         ]
index 714d59c08565fb98fdd3f7bbd940003067c53893..63c138dd64f57691a64f2dba8199ccd5442faf20 100644 (file)
@@ -1,3 +1,4 @@
+import inspect
 import unittest
 try:
     import mock
@@ -5,12 +6,14 @@ except ImportError:
     from unittest import mock
 
 from orchestrator import InventoryHost
+from orchestrator import Orchestrator as OrchestratorBase
 
 from . import ControllerTestCase
 from .. import mgr
 from ..controllers.orchestrator import get_device_osd_map
 from ..controllers.orchestrator import Orchestrator
 from ..controllers.orchestrator import OrchestratorInventory
+from ..services.orchestrator import OrchFeature
 
 
 class OrchestratorControllerTest(ControllerTestCase):
@@ -81,6 +84,7 @@ class OrchestratorControllerTest(ControllerTestCase):
         ]
         fake_client = mock.Mock()
         fake_client.available.return_value = True
+        fake_client.get_missing_features.return_value = []
         self._set_inventory(fake_client, inventory)
         instance.return_value = fake_client
 
@@ -156,3 +160,11 @@ class TestOrchestrator(unittest.TestCase):
                 'sda': [2]
             }
         })
+
+    def test_features_has_corresponding_methods(self):
+        defined_methods = [v for k, v in inspect.getmembers(
+            OrchFeature, lambda m: not inspect.isroutine(m)) if not k.startswith('_')]
+        orchestrator_methods = [k for k, v in inspect.getmembers(
+            OrchestratorBase, inspect.isroutine)]
+        for method in defined_methods:
+            self.assertIn(method, orchestrator_methods)
index aeb32ed576452503c1eb4c5f5743bf4d1684a912..5bbaf39afb7e59978fe1c871a129b2b6a6cc2364 100644 (file)
@@ -295,6 +295,7 @@ class OsdTest(ControllerTestCase):
 
         # With orchestrator service
         fake_client.available.return_value = True
+        fake_client.get_missing_features.return_value = []
         self._task_post('/api/osd', data)
         self.assertStatus(201)
         dg_specs = [DriveGroupSpec(placement=PlacementSpec(host_pattern='*'),
@@ -308,6 +309,7 @@ class OsdTest(ControllerTestCase):
         # without orchestrator service
         fake_client = mock.Mock()
         instance.return_value = fake_client
+        fake_client.get_missing_features.return_value = []
 
         # Invalid DriveGroup
         data = {