]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add host labels in UI
authorVolker Theile <vtheile@suse.com>
Thu, 4 Jun 2020 13:57:34 +0000 (15:57 +0200)
committerVolker Theile <vtheile@suse.com>
Wed, 24 Jun 2020 10:16:33 +0000 (12:16 +0200)
Fixes: https://tracker.ceph.com/issues/45897
Signed-off-by: Volker Theile <vtheile@suse.com>
s

s

s

src/pybind/mgr/dashboard/controllers/host.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory-devices/inventory-devices.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts
src/pybind/mgr/dashboard/services/orchestrator.py
src/pybind/mgr/dashboard/tests/test_host.py

index 5995161bee1098d5e5eb4e929203b167db2ed636..0426708e7e261500274d0393470d8354dccd2c18 100644 (file)
@@ -1,12 +1,16 @@
 # -*- 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
@@ -22,7 +26,8 @@ def host_task(name, metadata, wait_for=10.0):
 
 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
@@ -31,31 +36,31 @@ def merge_hosts_by_hostname(ceph_hosts, orch_hosts):
     :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
@@ -68,13 +73,17 @@ def get_hosts(from_ceph=True, from_orchestrator=True):
     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()
@@ -83,6 +92,18 @@ def get_hosts(from_ceph=True, from_orchestrator=True):
     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):
@@ -145,3 +166,53 @@ class Host(RESTController):
         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.
index cb103afab6d12cd31361f013104215c90d048cfe..9cc62f48b3da47b73eb9be412a4ecd2d9335fb23 100644 (file)
@@ -88,4 +88,32 @@ describe('HostsComponent', () => {
       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();
+    });
+  });
 });
index 91e9faa7f2cbe67c45d07d5d6b9a808f9f2686e7..50b04947c0d7aca4334bfad1524c4095842f9a2c 100644 (file)
@@ -7,8 +7,12 @@ import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
 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';
@@ -19,6 +23,7 @@ import { CephShortVersionPipe } from '../../../shared/pipes/ceph-short-version.p
 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';
 
@@ -31,6 +36,11 @@ const BASE_URL = 'hosts';
   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> = [];
@@ -40,9 +50,6 @@ export class HostsComponent extends ListWithDetails implements OnInit {
   selection = new CdTableSelection();
   modalRef: BsModalRef;
 
-  @ViewChild('servicesTpl', { static: true })
-  public servicesTpl: TemplateRef<any>;
-
   constructor(
     private authStorageService: AuthStorageService,
     private hostService: HostService,
@@ -54,7 +61,8 @@ export class HostsComponent extends ListWithDetails implements OnInit {
     private modalService: BsModalService,
     private taskWrapper: TaskWrapperService,
     private router: Router,
-    private depCheckerService: DepCheckerService
+    private depCheckerService: DepCheckerService,
+    private notificationService: NotificationService
   ) {
     super();
     this.permissions = this.authStorageService.getPermissions();
@@ -73,6 +81,21 @@ export class HostsComponent extends ListWithDetails implements OnInit {
           );
         }
       },
+      {
+        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',
@@ -81,7 +104,7 @@ export class HostsComponent extends ListWithDetails implements OnInit {
           this.depCheckerService.checkOrchestratorOrModal(
             this.actionLabels.DELETE,
             this.i18n('Host'),
-            () => this.deleteHostModal()
+            () => this.deleteAction()
           );
         },
         disable: () => !this.selection.hasSelection
@@ -121,7 +144,63 @@ export class HostsComponent extends ListWithDetails implements OnInit {
     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: {
index 19d6e5cce081cdd359cb37712bba0fc24694bc61..1a48903510c1a46aceccd9a93d694bbe77377c22 100644 (file)
@@ -193,13 +193,15 @@ export class InventoryDevicesComponent implements OnInit, OnDestroy {
             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'),
index 1b80b3f77880bef143bd883e3462cbb0d794fbc4..d4a36700e65a2cb83cf992b6f1b38422cab5a635 100644 (file)
@@ -42,4 +42,11 @@ describe('HostService', () => {
     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'] });
+  }));
 });
index fef43950cc70b93f149211a66449191d9e213d28..f016f2c11da6b5b7915871803add578c4646a3ef 100644 (file)
@@ -42,4 +42,12 @@ export class HostService {
   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 });
+  }
 }
index b7ce9aceab5142679f793ed290c5482894568856..2ccb7893f64b4d483d8be2e6b1bc02630754dbd8 100755 (executable)
@@ -13,6 +13,7 @@
           <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) }}
index 5f5712c7bfebc8961c7c9f3fe5e7d81a8b0a594e..955f4f4e0bdcaec1d50981b3e5c5222128a4cbc2 100644 (file)
 <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>
index 7b2fe3f941273df3481435fe8d09ff7d2e155588..e327be59a27a45722f8b28f85a14604273611994 100644 (file)
@@ -1,19 +1,32 @@
 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;
+  };
 }
index cb394b8816ebddd5f5f3f306fb00469ca42c4221..ea33b9a37ee28e56fb107ee946dffe9e20804396 100644 (file)
@@ -66,6 +66,14 @@ class HostManger(ResourceManager):
     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
index c2ce606938083cd4ad859da32b67409638cabb7a..17683232bb6aaa7b1204dabfc290aeec08abed45 100644 (file)
@@ -8,7 +8,7 @@ except ImportError:
 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
 
 
@@ -23,26 +23,25 @@ class HostControllerTest(ControllerTestCase):
 
     @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 = []
@@ -52,6 +51,7 @@ class HostControllerTest(ControllerTestCase):
                 _hosts.append(hosts[1])
                 _hosts.append(hosts[2])
             return _hosts
+
         mock_get_hosts.side_effect = _get_hosts
 
         self._get(self.URL_HOST)
@@ -70,25 +70,118 @@ class HostControllerTest(ControllerTestCase):
         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']