# -*- coding: utf-8 -*-
from __future__ import absolute_import
+
import copy
-from typing import List
+from typing import List, Dict
+
+import cherrypy
from mgr_util import merge_dicts
from orchestrator import HostSpec
-from . import ApiController, RESTController, Task
+from . import ApiController, RESTController, Task, Endpoint, ReadPermission, \
+ UiApiController, BaseController
from .orchestrator import raise_if_no_orchestrator
from .. import mgr
from ..exceptions import DashboardException
def merge_hosts_by_hostname(ceph_hosts, orch_hosts):
# type: (List[dict], List[HostSpec]) -> List[dict]
- """Merge Ceph hosts with orchestrator hosts by hostnames.
+ """
+ Merge Ceph hosts with orchestrator hosts by hostnames.
:param ceph_hosts: hosts returned from mgr
:type ceph_hosts: list of dict
:return list of dict
"""
hosts = copy.deepcopy(ceph_hosts)
- orch_hosts_map = {
- host.hostname: {
- 'labels': host.labels
- }
- for host in orch_hosts
- }
-
- # Hosts in both Ceph and Orchestrator
+ orch_hosts_map = {host.hostname: host.to_json() for host in orch_hosts}
+
+ # Sort labels.
+ for hostname in orch_hosts_map:
+ orch_hosts_map[hostname]['labels'].sort()
+
+ # Hosts in both Ceph and Orchestrator.
for host in hosts:
hostname = host['hostname']
if hostname in orch_hosts_map:
- host['labels'] = orch_hosts_map[hostname]['labels']
+ host = merge_dicts(host, orch_hosts_map[hostname])
host['sources']['orchestrator'] = True
orch_hosts_map.pop(hostname)
- # Hosts only in Orchestrator
+ # Hosts only in Orchestrator.
orch_hosts_only = [
- dict(hostname=hostname,
- ceph_version='',
- labels=orch_hosts_map[hostname]['labels'],
- services=[],
- sources={
- 'ceph': False,
- 'orchestrator': True
- }) for hostname in orch_hosts_map
+ merge_dicts(
+ {
+ 'ceph_version': '',
+ 'services': [],
+ 'sources': {
+ 'ceph': False,
+ 'orchestrator': True
+ }
+ }, orch_hosts_map[hostname]) for hostname in orch_hosts_map
]
hosts.extend(orch_hosts_only)
return hosts
ceph_hosts = []
if from_ceph:
ceph_hosts = [
- merge_dicts(server, {
- 'labels': [],
- 'sources': {
- 'ceph': True,
- 'orchestrator': False
- }
- }) for server in mgr.list_servers()
+ merge_dicts(
+ server, {
+ 'addr': '',
+ 'labels': [],
+ 'service_type': '',
+ 'sources': {
+ 'ceph': True,
+ 'orchestrator': False
+ },
+ 'status': ''
+ }) for server in mgr.list_servers()
]
if from_orchestrator:
orch = OrchClient.instance()
return ceph_hosts
+def get_host(hostname: str) -> Dict:
+ """
+ Get a specific host from Ceph or Orchestrator (if available).
+ :param hostname: The name of the host to fetch.
+ :raises: cherrypy.HTTPError: If host not found.
+ """
+ for host in get_hosts():
+ if host['hostname'] == hostname:
+ return host
+ raise cherrypy.HTTPError(404)
+
+
@ApiController('/host', Scope.HOSTS)
class Host(RESTController):
def list(self, sources=None):
orch = OrchClient.instance()
daemons = orch.services.list_daemons(None, hostname)
return [d.to_json() for d in daemons]
+
+ @handle_orchestrator_error('host')
+ def get(self, hostname: str) -> Dict:
+ """
+ Get the specified host.
+ :raises: cherrypy.HTTPError: If host not found.
+ """
+ return get_host(hostname)
+
+ @raise_if_no_orchestrator
+ @handle_orchestrator_error('host')
+ def set(self, hostname: str, labels: List[str]):
+ """
+ Update the specified host.
+ Note, this is only supported when Ceph Orchestrator is enabled.
+ :param hostname: The name of the host to be processed.
+ :param labels: List of labels.
+ """
+ orch = OrchClient.instance()
+ host = get_host(hostname)
+ current_labels = set(host['labels'])
+ # Remove labels.
+ remove_labels = list(current_labels.difference(set(labels)))
+ for label in remove_labels:
+ orch.hosts.remove_label(hostname, label)
+ # Add labels.
+ add_labels = list(set(labels).difference(current_labels))
+ for label in add_labels:
+ orch.hosts.add_label(hostname, label)
+
+
+@UiApiController('/host', Scope.HOSTS)
+class HostUi(BaseController):
+ @Endpoint('GET')
+ @ReadPermission
+ @handle_orchestrator_error('host')
+ def labels(self) -> List[str]:
+ """
+ Get all host labels.
+ Note, host labels are only supported when Ceph Orchestrator is enabled.
+ If Ceph Orchestrator is not enabled, an empty list is returned.
+ :return: A list of all host labels.
+ """
+ labels = []
+ orch = OrchClient.instance()
+ if orch.available():
+ for host in orch.hosts.list():
+ labels.extend(host.labels)
+ labels.sort()
+ return list(set(labels)) # Filter duplicate labels.
expect(spans[0].textContent).toBe(hostname);
});
}));
+
+ describe('getEditDisableDesc', () => {
+ it('should return message (not managed by Orchestrator)', () => {
+ component.selection.add({
+ sources: {
+ ceph: true,
+ orchestrator: false
+ }
+ });
+ expect(component.getEditDisableDesc(component.selection)).toBe(
+ 'Host editing is disabled because the host is not managed by Orchestrator.'
+ );
+ });
+
+ it('should return undefined (no selection)', () => {
+ expect(component.getEditDisableDesc()).toBeUndefined();
+ });
+
+ it('should return undefined (managed by Orchestrator)', () => {
+ component.selection.add({
+ sources: {
+ ceph: false,
+ orchestrator: true
+ }
+ });
+ expect(component.getEditDisableDesc()).toBeUndefined();
+ });
+ });
});
import { HostService } from '../../../shared/api/host.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 { SelectMessages } from '../../../shared/components/select/select-messages.model';
import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
+import { TableComponent } from '../../../shared/datatable/table/table.component';
import { Icons } from '../../../shared/enum/icons.enum';
+import { NotificationType } from '../../../shared/enum/notification-type.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 { JoinPipe } from '../../../shared/pipes/join.pipe';
import { AuthStorageService } from '../../../shared/services/auth-storage.service';
import { DepCheckerService } from '../../../shared/services/dep-checker.service';
+import { NotificationService } from '../../../shared/services/notification.service';
import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
import { URLBuilderService } from '../../../shared/services/url-builder.service';
providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
})
export class HostsComponent extends ListWithDetails implements OnInit {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+ @ViewChild('servicesTpl', { static: true })
+ public servicesTpl: TemplateRef<any>;
+
permissions: Permissions;
columns: Array<CdTableColumn> = [];
hosts: Array<object> = [];
selection = new CdTableSelection();
modalRef: BsModalRef;
- @ViewChild('servicesTpl', { static: true })
- public servicesTpl: TemplateRef<any>;
-
constructor(
private authStorageService: AuthStorageService,
private hostService: HostService,
private modalService: BsModalService,
private taskWrapper: TaskWrapperService,
private router: Router,
- private depCheckerService: DepCheckerService
+ private depCheckerService: DepCheckerService,
+ private notificationService: NotificationService
) {
super();
this.permissions = this.authStorageService.getPermissions();
);
}
},
+ {
+ name: this.actionLabels.EDIT,
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => {
+ this.depCheckerService.checkOrchestratorOrModal(
+ this.actionLabels.EDIT,
+ this.i18n('Host'),
+ () => this.editAction()
+ );
+ },
+ disable: (selection: CdTableSelection) =>
+ !selection.hasSingleSelection || !selection.first().sources.orchestrator,
+ disableDesc: () => this.getEditDisableDesc()
+ },
{
name: this.actionLabels.DELETE,
permission: 'delete',
this.depCheckerService.checkOrchestratorOrModal(
this.actionLabels.DELETE,
this.i18n('Host'),
- () => this.deleteHostModal()
+ () => this.deleteAction()
);
},
disable: () => !this.selection.hasSelection
this.selection = selection;
}
- deleteHostModal() {
+ editAction() {
+ this.hostService.getLabels().subscribe((resp: string[]) => {
+ const host = this.selection.first();
+ const allLabels = resp.map((label) => {
+ return { enabled: true, name: label };
+ });
+ this.modalService.show(FormModalComponent, {
+ initialState: {
+ titleText: this.i18n('Edit Host: {{hostname}}', host),
+ fields: [
+ {
+ type: 'select-badges',
+ name: 'labels',
+ value: host['labels'],
+ label: this.i18n('Labels'),
+ typeConfig: {
+ customBadges: true,
+ options: allLabels,
+ messages: new SelectMessages(
+ {
+ empty: this.i18n('There are no labels.'),
+ filter: this.i18n('Filter or add labels'),
+ add: this.i18n('Add label')
+ },
+ this.i18n
+ )
+ }
+ }
+ ],
+ submitButtonText: this.i18n('Edit Host'),
+ onSubmit: (values: any) => {
+ this.hostService.update(host['hostname'], values.labels).subscribe(() => {
+ this.notificationService.show(
+ NotificationType.success,
+ this.i18n('Updated Host "{{hostname}}"', host)
+ );
+ // Reload the data table content.
+ this.table.refreshBtn();
+ });
+ }
+ }
+ });
+ });
+ }
+
+ getEditDisableDesc(): string | undefined {
+ if (
+ this.selection &&
+ this.selection.hasSingleSelection &&
+ !this.selection.first().sources.orchestrator
+ ) {
+ return this.i18n('Host editing is disabled because the host is not managed by Orchestrator.');
+ }
+ return undefined;
+ }
+
+ deleteAction() {
const hostname = this.selection.first().hostname;
this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
initialState: {
name: 'duration',
value: 300,
required: true,
- options: [
- { text: this.i18n('1 minute'), value: 60 },
- { text: this.i18n('2 minutes'), value: 120 },
- { text: this.i18n('5 minutes'), value: 300 },
- { text: this.i18n('10 minutes'), value: 600 },
- { text: this.i18n('15 minutes'), value: 900 }
- ]
+ typeConfig: {
+ options: [
+ { text: this.i18n('1 minute'), value: 60 },
+ { text: this.i18n('2 minutes'), value: 120 },
+ { text: this.i18n('5 minutes'), value: 300 },
+ { text: this.i18n('10 minutes'), value: 600 },
+ { text: this.i18n('15 minutes'), value: 900 }
+ ]
+ }
}
],
submitButtonText: this.i18n('Execute'),
const req = httpTesting.expectOne(`api/host/${hostname}/devices`);
expect(req.request.method).toBe('GET');
});
+
+ it('should update host', fakeAsync(() => {
+ service.update('mon0', ['foo', 'bar']).subscribe();
+ const req = httpTesting.expectOne('api/host/mon0');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual({ labels: ['foo', 'bar'] });
+ }));
});
getDaemons(hostname: string): Observable<Daemon[]> {
return this.http.get<Daemon[]>(`${this.baseURL}/${hostname}/daemons`);
}
+
+ getLabels(): Observable<string[]> {
+ return this.http.get<string[]>('ui-api/host/labels');
+ }
+
+ update(hostname: string, labels: string[]) {
+ return this.http.put(`${this.baseURL}/${hostname}`, { labels: labels });
+ }
}
<div class="form-group row cd-{{field.name}}-form-group">
<label *ngIf="field.label"
class="cd-col-form-label"
+ [ngClass]="{'required': field?.required === true}"
[for]="field.name">
{{ field.label }}
</label>
class="form-control custom-select"
[id]="field.name"
[formControlName]="field.name">
- <option *ngIf="field.placeholder"
+ <option *ngIf="field?.typeConfig?.placeholder"
[ngValue]="null">
- {{ field.placeholder }}
+ {{ field?.typeConfig?.placeholder }}
</option>
- <option *ngFor="let option of field.options"
+ <option *ngFor="let option of field?.typeConfig?.options"
[value]="option.value">
{{ option.text }}
</option>
</select>
+ <cd-select-badges *ngIf="field.type === 'select-badges'"
+ [id]="field.name"
+ [data]="field.value"
+ [customBadges]="field?.typeConfig?.customBadges"
+ [options]="field?.typeConfig?.options"
+ [messages]="field?.typeConfig?.messages">
+ </cd-select-badges>
<span *ngIf="formGroup.showError(field.name, formDir)"
class="invalid-feedback">
{{ getError(field) }}
<a class="select-menu-edit float-left"
[ngClass]="elemClass"
[ngbPopover]="popTemplate"
- *ngIf="options.length > 0">
+ *ngIf="customBadges || options.length > 0">
<ng-content></ng-content>
</a>
<span class="form-text text-muted float-left"
- *ngIf="data.length === 0 && options.length > 0">
+ *ngIf="data.length === 0 && !(!customBadges && options.length === 0)">
{{ messages.empty }}
</span>
<span class="form-text text-muted float-left"
- *ngIf="options.length === 0">
+ *ngIf="!customBadges && options.length === 0">
{{ messages.noOptions }}
</span>
import { ValidatorFn } from '@angular/forms';
export class CdFormModalFieldConfig {
+ // --- Generic field properties ---
name: string;
// 'binary' will use cdDimlessBinary directive on input element
// 'select' will use select element
- type: 'number' | 'text' | 'binary' | 'select';
+ type: 'number' | 'text' | 'binary' | 'select' | 'select-badges';
label?: string;
required?: boolean;
value?: any;
errors?: { [errorName: string]: string };
validators: ValidatorFn[];
- // only for type select
- placeholder?: string;
- options?: Array<{
- text: string;
- value: any;
- }>;
+
+ // --- Specific field properties ---
+ typeConfig?: {
+ [prop: string]: any;
+ // 'select':
+ // ---------
+ // placeholder?: string;
+ // options?: Array<{
+ // text: string;
+ // value: any;
+ // }>;
+ //
+ // 'select-badges':
+ // ----------------
+ // customBadges: boolean;
+ // options: Array<SelectOption>;
+ // messages: SelectMessages;
+ };
}
def remove(self, hostname: str):
return self.api.remove_host(hostname)
+ @wait_api_result
+ def add_label(self, host: str, label: str) -> Completion:
+ return self.api.add_host_label(host, label)
+
+ @wait_api_result
+ def remove_label(self, host: str, label: str) -> Completion:
+ return self.api.remove_host_label(host, label)
+
class InventoryManager(ResourceManager):
@wait_api_result
from orchestrator import HostSpec
from . import ControllerTestCase
-from ..controllers.host import get_hosts, Host
+from ..controllers.host import get_hosts, Host, HostUi
from .. import mgr
@mock.patch('dashboard.controllers.host.get_hosts')
def test_host_list(self, mock_get_hosts):
- hosts = [
- {
- 'hostname': 'host-0',
- 'sources': {
- 'ceph': True, 'orchestrator': False
- }
- },
- {
- 'hostname': 'host-1',
- 'sources': {
- 'ceph': False, 'orchestrator': True
- }
- },
- {
- 'hostname': 'host-2',
- 'sources': {
- 'ceph': True, 'orchestrator': True
- }
+ hosts = [{
+ 'hostname': 'host-0',
+ 'sources': {
+ 'ceph': True,
+ 'orchestrator': False
}
- ]
+ }, {
+ 'hostname': 'host-1',
+ 'sources': {
+ 'ceph': False,
+ 'orchestrator': True
+ }
+ }, {
+ 'hostname': 'host-2',
+ 'sources': {
+ 'ceph': True,
+ 'orchestrator': True
+ }
+ }]
def _get_hosts(from_ceph=True, from_orchestrator=True):
_hosts = []
_hosts.append(hosts[1])
_hosts.append(hosts[2])
return _hosts
+
mock_get_hosts.side_effect = _get_hosts
self._get(self.URL_HOST)
self.assertStatus(200)
self.assertJsonBody(hosts)
+ @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance')
+ def test_get_1(self, instance):
+ mgr.list_servers.return_value = []
-class TestHosts(unittest.TestCase):
+ fake_client = mock.Mock()
+ fake_client.available.return_value = False
+ instance.return_value = fake_client
+
+ self._get('{}/node1'.format(self.URL_HOST))
+ self.assertStatus(404)
+ @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance')
+ def test_get_2(self, instance):
+ mgr.list_servers.return_value = [{'hostname': 'node1'}]
+
+ fake_client = mock.Mock()
+ fake_client.available.return_value = False
+ instance.return_value = fake_client
+
+ self._get('{}/node1'.format(self.URL_HOST))
+ self.assertStatus(200)
+ self.assertIn('labels', self.json_body())
+
+ @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance')
+ def test_get_3(self, instance):
+ mgr.list_servers.return_value = []
+
+ fake_client = mock.Mock()
+ fake_client.available.return_value = True
+ fake_client.hosts.list.return_value = [HostSpec('node1')]
+ instance.return_value = fake_client
+
+ self._get('{}/node1'.format(self.URL_HOST))
+ self.assertStatus(200)
+ self.assertIn('labels', self.json_body())
+
+ @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance')
+ def test_set_labels(self, instance):
+ mgr.list_servers.return_value = []
+
+ fake_client = mock.Mock()
+ fake_client.available.return_value = True
+ fake_client.hosts.list.return_value = [
+ HostSpec('node0', labels=['aaa', 'bbb'])
+ ]
+ fake_client.hosts.remove_label = mock.Mock()
+ fake_client.hosts.add_label = mock.Mock()
+ instance.return_value = fake_client
+
+ self._put('{}/node0'.format(self.URL_HOST), {'labels': ['bbb', 'ccc']})
+ self.assertStatus(200)
+ fake_client.hosts.remove_label.assert_called_once_with('node0', 'aaa')
+ fake_client.hosts.add_label.assert_called_once_with('node0', 'ccc')
+
+
+class HostUiControllerTest(ControllerTestCase):
+ URL_HOST = '/ui-api/host'
+
+ @classmethod
+ def setup_server(cls):
+ # pylint: disable=protected-access
+ HostUi._cp_config['tools.authenticate.on'] = False
+ cls.setup_controllers([HostUi])
+
+ @mock.patch('dashboard.controllers.orchestrator.OrchClient.instance')
+ def test_labels(self, instance):
+ fake_client = mock.Mock()
+ fake_client.available.return_value = True
+ fake_client.hosts.list.return_value = [
+ HostSpec('node1', labels=['foo']),
+ HostSpec('node2', labels=['foo', 'bar'])
+ ]
+ instance.return_value = fake_client
+
+ self._get('{}/labels'.format(self.URL_HOST))
+ self.assertStatus(200)
+ labels = self.json_body()
+ labels.sort()
+ self.assertListEqual(labels, ['bar', 'foo'])
+
+
+class TestHosts(unittest.TestCase):
@mock.patch('dashboard.controllers.orchestrator.OrchClient.instance')
def test_get_hosts(self, instance):
- mgr.list_servers.return_value = [{'hostname': 'node1'}, {'hostname': 'localhost'}]
+ mgr.list_servers.return_value = [{
+ 'hostname': 'node1'
+ }, {
+ 'hostname': 'localhost'
+ }]
fake_client = mock.Mock()
fake_client.available.return_value = True
fake_client.hosts.list.return_value = [
- HostSpec('node1'), HostSpec('node2')]
+ HostSpec('node1'), HostSpec('node2')
+ ]
instance.return_value = fake_client
hosts = get_hosts()
self.assertEqual(len(hosts), 3)
check_sources = {
- 'localhost': {'ceph': True, 'orchestrator': False},
- 'node1': {'ceph': True, 'orchestrator': True},
- 'node2': {'ceph': False, 'orchestrator': True}
+ 'localhost': {
+ 'ceph': True,
+ 'orchestrator': False
+ },
+ 'node1': {
+ 'ceph': True,
+ 'orchestrator': True
+ },
+ 'node2': {
+ 'ceph': False,
+ 'orchestrator': True
+ }
}
for host in hosts:
hostname = host['hostname']