From: Kiefer Chang Date: Wed, 6 May 2020 08:20:06 +0000 (+0800) Subject: mgr/dashboard: leverage features set from orchestrator X-Git-Tag: wip-pdonnell-testing-20200918.022351~342^2~1 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=43dca1343f635d8a3de1dfa8d9133211c6bc7fe4;p=ceph-ci.git mgr/dashboard: leverage features set from orchestrator 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 --- diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py index db498c8aaad..100b676ef10 100644 --- a/src/pybind/mgr/dashboard/controllers/host.py +++ b/src/pybind/mgr/dashboard/controllers/host.py @@ -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]): """ diff --git a/src/pybind/mgr/dashboard/controllers/orchestrator.py b/src/pybind/mgr/dashboard/controllers/orchestrator.py index 14ddd39298a..c26558c70b1 100644 --- a/src/pybind/mgr/dashboard/controllers/orchestrator.py +++ b/src/pybind/mgr/dashboard/controllers/orchestrator.py @@ -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 diff --git a/src/pybind/mgr/dashboard/controllers/osd.py b/src/pybind/mgr/dashboard/controllers/osd.py index caff46e5eaa..69e642501d6 100644 --- a/src/pybind/mgr/dashboard/controllers/osd.py +++ b/src/pybind/mgr/dashboard/controllers/osd.py @@ -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): """ diff --git a/src/pybind/mgr/dashboard/controllers/service.py b/src/pybind/mgr/dashboard/controllers/service.py index baacff080dd..9e0294bdb61 100644 --- a/src/pybind/mgr/dashboard/controllers/service.py +++ b/src/pybind/mgr/dashboard/controllers/service.py @@ -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 index 00000000000..83881979039 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/fixtures/host_list_response.json @@ -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": "" + } +] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts index 0c2931f3af7..391d87f7a0c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts @@ -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; 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); }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts index 225eba56918..a095f6c6b77 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts @@ -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 index 00000000000..8a6986a35a9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/fixtures/inventory_list_response.json @@ -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": [] + } +] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts index 1ce48f6fd00..b05f37af319 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.spec.ts @@ -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; + 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); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts index 4b99c747396..d6ec52e680e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts @@ -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; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html index 70a0d0ef50e..e05f6dc59e1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.html @@ -1,12 +1,13 @@ - - + + Devices
+ (fetchInventory)="refresh()" + [orchStatus]="orchStatus">
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts index da0f1a541a1..2c2ba53c00e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts @@ -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 = []; @@ -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 index 00000000000..83590a12b88 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/fixtures/osd_list_response.json @@ -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] + ] + } + } +] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts index f928e4de4b0..52ccec23622 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.spec.ts @@ -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; 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); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts index 96c38fff94b..ef9483aab25 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts @@ -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 ); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html index 64ec411aca6..1db551591f5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/services.component.html @@ -1,5 +1,5 @@ - - + + = []; services: Array = []; @@ -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; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts index df05ff9c695..0fe9883f9e3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts @@ -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 { + return this.http.get(`${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) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 52938b6ad10..95cc5ae0e39 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -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 index 338be7df6dc..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.html +++ /dev/null @@ -1,16 +0,0 @@ - - {{ actionDescription }} {{ itemDescription }} - - - - - - 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 index e69de29bb2d..00000000000 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 index ef4ed3eb12c..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.spec.ts +++ /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; - - 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 index d8fd210b8a2..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-modal/orchestrator-doc-modal.component.ts +++ /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(); - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html index 4c7175910b0..f33261d8019 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html @@ -1,4 +1,10 @@ -Orchestrator is not available. - Please consult the on how to configure and - enable the functionality. +The feature is not supported in the current Orchestrator. + + + Orchestrator is not available. + Please consult the on how to configure and + enable the functionality. + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts index 71c94ec7fac..946f7efa88a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts @@ -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 index 00000000000..50a70df8633 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts @@ -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 index 00000000000..feed4a88271 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts @@ -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 index 1ae3095a095..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/dep-checker.service.spec.ts +++ /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 index 95cf292bcc1..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/dep-checker.service.ts +++ /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 - }); - } - }); - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts index c260a6fe0c1..c31d70eadd8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts +++ b/src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts @@ -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, + 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); + }; +} diff --git a/src/pybind/mgr/dashboard/services/ganesha.py b/src/pybind/mgr/dashboard/services/ganesha.py index e9144db406e..c5fc4aa63ee 100644 --- a/src/pybind/mgr/dashboard/services/ganesha.py +++ b/src/pybind/mgr/dashboard/services/ganesha.py @@ -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): diff --git a/src/pybind/mgr/dashboard/services/orchestrator.py b/src/pybind/mgr/dashboard/services/orchestrator.py index 282674ee159..7841bf51983 100644 --- a/src/pybind/mgr/dashboard/services/orchestrator.py +++ b/src/pybind/mgr/dashboard/services/orchestrator.py @@ -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' diff --git a/src/pybind/mgr/dashboard/tests/test_host.py b/src/pybind/mgr/dashboard/tests/test_host.py index ab7286074b7..054d6559aac 100644 --- a/src/pybind/mgr/dashboard/tests/test_host.py +++ b/src/pybind/mgr/dashboard/tests/test_host.py @@ -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']) ] diff --git a/src/pybind/mgr/dashboard/tests/test_orchestrator.py b/src/pybind/mgr/dashboard/tests/test_orchestrator.py index 714d59c0856..63c138dd64f 100644 --- a/src/pybind/mgr/dashboard/tests/test_orchestrator.py +++ b/src/pybind/mgr/dashboard/tests/test_orchestrator.py @@ -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) diff --git a/src/pybind/mgr/dashboard/tests/test_osd.py b/src/pybind/mgr/dashboard/tests/test_osd.py index aeb32ed5764..5bbaf39afb7 100644 --- a/src/pybind/mgr/dashboard/tests/test_osd.py +++ b/src/pybind/mgr/dashboard/tests/test_osd.py @@ -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 = {