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