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
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
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
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)
"""
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]):
"""
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
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
@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
@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
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
}
@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
'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."""
@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):
"""
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
"""
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)
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
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):
--- /dev/null
+[
+ {
+ "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": ""
+ }
+]
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';
let component: HostsComponent;
let fixture: ComponentFixture<HostsComponent>;
let hostListSpy: jasmine.Spy;
+ let orchService: OrchestratorService;
const fakeAuthStorageService = {
getPermissions: () => {
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', () => {
}
];
+ OrchestratorHelper.mockStatus(true);
hostListSpy.and.callFake(() => of(payload));
+ fixture.detectChanges();
return fixture.whenStable().then(() => {
fixture.detectChanges();
});
});
- 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);
});
});
});
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';
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';
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,
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();
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)
}
];
}
pipe: this.cephShortVersionPipe
}
];
+ this.orchService.status().subscribe((status: OrchestratorStatus) => {
+ this.orchStatus = status;
+ });
}
updateSelection(selection: CdTableSelection) {
});
}
- 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() {
});
}
- 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;
--- /dev/null
+[
+ {
+ "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": []
+ }
+]
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
import * as _ from 'lodash';
import { ToastrModule } from 'ngx-toastr';
import { configureTestBed } from '../../../../../testing/unit-test-helper';
+import { OrchestratorService } from '../../../../shared/api/orchestrator.service';
+import { TableActionsComponent } from '../../../../shared/datatable/table-actions/table-actions.component';
+import { CdTableAction } from '../../../../shared/models/cd-table-action';
+import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
+import { OrchestratorFeature } from '../../../../shared/models/orchestrator.enum';
+import { Permissions } from '../../../../shared/models/permissions';
+import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
import { SharedModule } from '../../../../shared/shared.module';
import { InventoryDevicesComponent } from './inventory-devices.component';
describe('InventoryDevicesComponent', () => {
let component: InventoryDevicesComponent;
let fixture: ComponentFixture<InventoryDevicesComponent>;
+ let orchService: OrchestratorService;
+
+ const fakeAuthStorageService = {
+ getPermissions: () => {
+ return new Permissions({ osd: ['read', 'update', 'create', 'delete'] });
+ }
+ };
+
+ const mockOrchStatus = (available: boolean, features?: OrchestratorFeature[]) => {
+ const orchStatus = { available: available, description: '', features: {} };
+ if (features) {
+ features.forEach((feature: OrchestratorFeature) => {
+ orchStatus.features[feature] = { available: true };
+ });
+ }
+ component.orchStatus = orchStatus;
+ };
configureTestBed({
imports: [
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', () => {
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);
+ });
+ });
});
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';
tableActions: CdTableAction[];
fetchInventorySub: Subscription;
+ @Input() orchStatus: OrchestratorStatus = undefined;
+
+ actionOrchFeatures = {
+ identify: [OrchestratorFeature.DEVICE_BLINK_LIGHT]
+ };
+
constructor(
private authStorageService: AuthStorageService,
private dimlessBinary: DimlessBinaryPipe,
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)
}
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;
}
-<cd-orchestrator-doc-panel *ngIf="!hasOrchestrator"></cd-orchestrator-doc-panel>
-<ng-container *ngIf="hasOrchestrator">
+<cd-orchestrator-doc-panel *ngIf="!orchStatus?.available"></cd-orchestrator-doc-panel>
+<ng-container *ngIf="orchStatus?.available">
<legend i18n>Devices</legend>
<div class="row">
<div class="col-md-12">
<cd-inventory-devices [devices]="devices"
[hiddenColumns]="hostname === undefined ? [] : ['hostname']"
selectionType="single"
- (fetchInventory)="refresh()">
+ (fetchInventory)="refresh()"
+ [orchStatus]="orchStatus">
</cd-inventory-devices>
</div>
</div>
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({
icons = Icons;
- hasOrchestrator = false;
+ orchStatus: OrchestratorStatus;
devices: Array<InventoryDevice> = [];
ngOnInit() {
this.orchService.status().subscribe((status) => {
- this.hasOrchestrator = status.available;
+ this.orchStatus = status;
if (status.available) {
this.getInventory();
}
}
ngOnChanges() {
- if (this.hasOrchestrator) {
+ if (this.orchStatus) {
this.devices = [];
this.getInventory();
}
--- /dev/null
+[
+ {
+ "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]
+ ]
+ }
+ }
+]
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';
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';
let fixture: ComponentFixture<OsdListComponent>;
let modalServiceShowSpy: jasmine.Spy;
let osdService: OsdService;
+ let orchService: OrchestratorService;
const fakeAuthStorageService = {
getPermissions: () => {
);
};
- 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({
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', () => {
expectOpensModal('Mark Lost', modalClass);
expectOpensModal('Purge', modalClass);
expectOpensModal('Destroy', modalClass);
- mockOrchestratorStatus();
+ mockOrch();
mockSafeToDelete();
expectOpensModal('Delete', modalClass);
});
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);
+ });
+ });
});
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';
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';
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']) {
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();
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
},
{
name: this.actionLabels.DELETE,
permission: 'delete',
click: () => this.delete(),
- disable: () => !this.hasOsdSelected,
+ disable: (selection: CdTableSelection) => this.getDisable('delete', selection),
icon: Icons.destroy
}
];
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]
+ );
}
/**
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
);
}
-<cd-orchestrator-doc-panel *ngIf="!hasOrchestrator"></cd-orchestrator-doc-panel>
-<ng-container *ngIf="hasOrchestrator">
+<cd-orchestrator-doc-panel *ngIf="!orchStatus?.available"></cd-orchestrator-doc-panel>
+<ng-container *ngIf="orchStatus?.available">
<cd-table [data]="services"
[columns]="columns"
identifier="service_name"
import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context';
import { CdTableSelection } from '../../../shared/models/cd-table-selection';
import { FinishedTask } from '../../../shared/models/finished-task';
+import { OrchestratorFeature } from '../../../shared/models/orchestrator.enum';
+import { OrchestratorStatus } from '../../../shared/models/orchestrator.interface';
import { Permissions } from '../../../shared/models/permissions';
import { CephServiceSpec } from '../../../shared/models/service.interface';
import { AuthStorageService } from '../../../shared/services/auth-storage.service';
permissions: Permissions;
tableActions: CdTableAction[];
- checkingOrchestrator = true;
- hasOrchestrator = false;
+ orchStatus: OrchestratorStatus;
+ actionOrchFeatures = {
+ create: [OrchestratorFeature.SERVICE_CREATE],
+ delete: [OrchestratorFeature.SERVICE_DELETE]
+ };
columns: Array<CdTableColumn> = [];
services: Array<CephServiceSpec> = [];
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)
}
];
}
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;
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'
export class OrchestratorService {
private url = 'api/orchestrator';
+ disableMessages = {
+ noOrchestrator: $localize`The feature is disabled because Orchestrator is not available.`,
+ missingFeature: $localize`The Orchestrator backend doesn't support this feature.`
+ };
+
constructor(private http: HttpClient) {}
- status(): Observable<{ available: boolean; description: string }> {
- return this.http.get<{ available: boolean; description: string }>(`${this.url}/status`);
+ status(): Observable<OrchestratorStatus> {
+ return this.http.get<OrchestratorStatus>(`${this.url}/status`);
+ }
+
+ hasFeature(status: OrchestratorStatus, features: OrchestratorFeature[]): boolean {
+ return _.every(features, (feature) => _.get(status.features, `${feature}.available`));
+ }
+
+ getTableActionDisableDesc(
+ status: OrchestratorStatus,
+ features: OrchestratorFeature[]
+ ): boolean | string {
+ if (!status) {
+ return false;
+ }
+ if (!status.available) {
+ return this.disableMessages.noOrchestrator;
+ }
+ if (!this.hasFeature(status, features)) {
+ return this.disableMessages.missingFeature;
+ }
+ return false;
}
identifyDevice(hostname: string, device: string, duration: number) {
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';
PwdExpirationNotificationComponent,
TelemetryNotificationComponent,
OrchestratorDocPanelComponent,
- OrchestratorDocModalComponent,
DateTimePickerComponent,
DocComponent
],
+++ /dev/null
-<cd-modal [modalRef]="activeModal">
- <ng-container class="modal-title"
- i18n>{{ actionDescription }} {{ itemDescription }}</ng-container>
-
- <ng-container class="modal-content">
- <div class="modal-body">
- <cd-orchestrator-doc-panel></cd-orchestrator-doc-panel>
- </div>
- <div class="modal-footer">
- <cd-back-button [back]="activeModal.close"
- name="Close"
- i18n-name>
- </cd-back-button>
- </div>
- </ng-container>
-</cd-modal>
+++ /dev/null
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { RouterTestingModule } from '@angular/router/testing';
-
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
-
-import { configureTestBed } from '../../../../testing/unit-test-helper';
-import { ComponentsModule } from '../components.module';
-import { OrchestratorDocModalComponent } from './orchestrator-doc-modal.component';
-
-describe('OrchestratorDocModalComponent', () => {
- let component: OrchestratorDocModalComponent;
- let fixture: ComponentFixture<OrchestratorDocModalComponent>;
-
- configureTestBed({
- imports: [ComponentsModule, HttpClientTestingModule, RouterTestingModule],
- providers: [NgbActiveModal]
- });
-
- beforeEach(() => {
- fixture = TestBed.createComponent(OrchestratorDocModalComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-});
+++ /dev/null
-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();
- }
-}
-<cd-alert-panel type="info"
- i18n>Orchestrator is not available.
- Please consult the <cd-doc section="orch"></cd-doc> on how to configure and
- enable the functionality.</cd-alert-panel>
+<cd-alert-panel *ngIf="missingFeatures; else elseBlock"
+ type="info"
+ i18n>The feature is not supported in the current Orchestrator.</cd-alert-panel>
+
+<ng-template #elseBlock>
+ <cd-alert-panel type="info"
+ i18n>Orchestrator is not available.
+ Please consult the <cd-doc section="orch"></cd-doc> on how to configure and
+ enable the functionality.</cd-alert-panel>
+</ng-template>
-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[];
+}
--- /dev/null
+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'
+}
--- /dev/null
+export interface OrchestratorStatus {
+ available: boolean;
+ description: string;
+ features: {
+ [feature: string]: {
+ available: boolean;
+ };
+ };
+}
+++ /dev/null
-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();
- });
-});
+++ /dev/null
-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
- });
- }
- });
- }
-}
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';
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,
return debugElem.queryAll(By.directive(NgbNavItem));
}
}
+
+export class OrchestratorHelper {
+ /**
+ * Mock Orchestrator status.
+ * @param available is the Orchestrator enabled?
+ * @param features A list of enabled Orchestrator features.
+ */
+ static mockStatus(available: boolean, features?: OrchestratorFeature[]) {
+ const orchStatus = { available: available, description: '', features: {} };
+ if (features) {
+ features.forEach((feature: OrchestratorFeature) => {
+ orchStatus.features[feature] = { available: true };
+ });
+ }
+ spyOn(TestBed.inject(OrchestratorService), 'status').and.callFake(() => of(orchStatus));
+ }
+}
+
+export class TableActionHelper {
+ /**
+ * Verify table action buttons, including the button disabled state and disable description.
+ *
+ * @param fixture test fixture
+ * @param tableActions table actions
+ * @param expectResult expected values. e.g. {Create: { disabled: true, disableDesc: 'not supported'}}.
+ * Expect the Create button to be disabled with 'not supported' tooltip.
+ */
+ static verifyTableActions = async (
+ fixture: ComponentFixture<any>,
+ tableActions: CdTableAction[],
+ expectResult: {
+ [action: string]: { disabled: boolean; disableDesc: string };
+ }
+ ) => {
+ // click dropdown to update all actions buttons
+ const dropDownToggle = fixture.debugElement.query(By.css('.dropdown-toggle'));
+ dropDownToggle.triggerEventHandler('click', null);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const tableActionElement = fixture.debugElement.query(By.directive(TableActionsComponent));
+ const toClassName = TestBed.inject(TableActionsComponent).toClassName;
+ const getActionElement = (action: CdTableAction) =>
+ tableActionElement.query(By.css(`[ngbDropdownItem].${toClassName(action.name)}`));
+
+ const actions = {};
+ tableActions.forEach((action) => {
+ const actionElement = getActionElement(action);
+ if (expectResult[action.name]) {
+ actions[action.name] = {
+ disabled: actionElement.classes.disabled,
+ disableDesc: actionElement.properties.title
+ };
+ }
+ });
+ expect(actions).toEqual(expectResult);
+ };
+}
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):
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
@classmethod
def instance(cls):
+ # type: () -> OrchClient
if cls._instance is None:
cls._instance = cls()
return cls._instance
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'
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'])
]
+import inspect
import unittest
try:
import mock
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):
]
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
'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)
# 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='*'),
# without orchestrator service
fake_client = mock.Mock()
instance.return_value = fake_client
+ fake_client.get_missing_features.return_value = []
# Invalid DriveGroup
data = {