]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: paginate hosts 52154/head
authorPere Diaz Bou <pere-altea@hotmail.com>
Thu, 22 Jun 2023 09:22:05 +0000 (11:22 +0200)
committerPere Diaz Bou <pere-altea@hotmail.com>
Tue, 8 Aug 2023 08:40:28 +0000 (10:40 +0200)
Signed-off-by: Pere Diaz Bou <pere-altea@hotmail.com>
Fixes: https://tracker.ceph.com/issues/56513
16 files changed:
src/pybind/mgr/dashboard/controllers/host.py
src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/hosts.po.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html
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/services/service-form/service-form.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/datatable/table/table.component.html
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/plugins/ttl_cache.py
src/pybind/mgr/dashboard/tests/test_host.py

index 9faaa519202c65e1353a81fd2c1e0fac7e31da8b..812b9c035ae3d7837c4231bf8cd199b8ae16d4c1 100644 (file)
@@ -1,6 +1,5 @@
 # -*- coding: utf-8 -*-
 
-import copy
 import os
 import time
 from collections import Counter
@@ -8,11 +7,12 @@ from typing import Dict, List, Optional
 
 import cherrypy
 from mgr_util import merge_dicts
-from orchestrator import HostSpec
 
 from .. import mgr
 from ..exceptions import DashboardException
+from ..plugins.ttl_cache import ttl_cache, ttl_cache_invalidator
 from ..security import Scope
+from ..services._paginate import ListPaginator
 from ..services.ceph_service import CephService
 from ..services.exception import handle_orchestrator_error
 from ..services.orchestrator import OrchClient, OrchFeature
@@ -117,51 +117,6 @@ 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):
-    # type: (List[dict], List[HostSpec]) -> List[dict]
-    """
-    Merge Ceph hosts with orchestrator hosts by hostnames.
-
-    :param ceph_hosts: hosts returned from mgr
-    :type ceph_hosts: list of dict
-    :param orch_hosts: hosts returned from ochestrator
-    :type orch_hosts: list of HostSpec
-    :return list of dict
-    """
-    hosts = copy.deepcopy(ceph_hosts)
-    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.update(orch_hosts_map[hostname])
-            host['sources']['orchestrator'] = True
-            orch_hosts_map.pop(hostname)
-
-    # Hosts only in Orchestrator.
-    orch_hosts_only = [
-        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)
-    for host in hosts:
-        host['service_instances'] = populate_service_instances(
-            host['hostname'], host['services'])
-    return hosts
-
-
 def populate_service_instances(hostname, services):
     orch = OrchClient.instance()
     if orch.available():
@@ -173,6 +128,7 @@ def populate_service_instances(hostname, services):
     return [{'type': k, 'count': v} for k, v in Counter(services).items()]
 
 
+@ttl_cache(60, label='get_hosts')
 def get_hosts(sources=None):
     """
     Get hosts from various sources.
@@ -184,6 +140,22 @@ def get_hosts(sources=None):
         from_ceph = 'ceph' in _sources
         from_orchestrator = 'orchestrator' in _sources
 
+    if from_orchestrator:
+        orch = OrchClient.instance()
+        if orch.available():
+            hosts = [
+                merge_dicts(
+                    {
+                        'ceph_version': '',
+                        'services': [],
+                        'sources': {
+                            'ceph': False,
+                            'orchestrator': True
+                        }
+                    }, host.to_json()) for host in orch.hosts.list()
+            ]
+            return hosts
+
     ceph_hosts = []
     if from_ceph:
         ceph_hosts = [
@@ -198,12 +170,6 @@ def get_hosts(sources=None):
                     'status': ''
                 }) 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())
-    for host in ceph_hosts:
-        host['service_instances'] = populate_service_instances(host['hostname'], host['services'])
     return ceph_hosts
 
 
@@ -303,14 +269,30 @@ class Host(RESTController):
                      'facts': (bool, 'Host Facts')
                  },
                  responses={200: LIST_HOST_SCHEMA})
-    @RESTController.MethodMap(version=APIVersion(1, 2))
-    def list(self, sources=None, facts=False):
+    @RESTController.MethodMap(version=APIVersion(1, 3))
+    def list(self, sources=None, facts=False, offset: int = 0,
+             limit: int = 5, search: str = '', sort: str = ''):
         hosts = get_hosts(sources)
+        params = ['hostname']
+        paginator = ListPaginator(int(offset), int(limit), sort, search, hosts,
+                                  searchable_params=params, sortable_params=params,
+                                  default_sort='+hostname')
+        # pylint: disable=unnecessary-comprehension
+        hosts = [host for host in paginator.list()]
         orch = OrchClient.instance()
+        cherrypy.response.headers['X-Total-Count'] = paginator.get_count()
+        for host in hosts:
+            if 'services' not in host:
+                host['services'] = []
+            host['service_instances'] = populate_service_instances(
+                host['hostname'], host['services'])
         if str_to_bool(facts):
             if orch.available():
                 if not orch.get_missing_features(['get_facts']):
-                    hosts_facts = orch.hosts.get_facts()
+                    hosts_facts = []
+                    for host in hosts:
+                        facts = orch.hosts.get_facts(host['hostname'])[0]
+                        hosts_facts.append(facts)
                     return merge_list_of_dicts_by_key(hosts, hosts_facts, 'hostname')
 
                 raise DashboardException(
@@ -430,13 +412,18 @@ class Host(RESTController):
         return [d.to_dict() for d in daemons]
 
     @handle_orchestrator_error('host')
+    @RESTController.MethodMap(version=APIVersion(1, 2))
     def get(self, hostname: str) -> Dict:
         """
         Get the specified host.
         :raises: cherrypy.HTTPError: If host not found.
         """
-        return get_host(hostname)
+        host = get_host(hostname)
+        host['service_instances'] = populate_service_instances(
+            host['hostname'], host['services'])
+        return host
 
+    @ttl_cache_invalidator('get_hosts')
     @raise_if_no_orchestrator([OrchFeature.HOST_LABEL_ADD,
                                OrchFeature.HOST_LABEL_REMOVE,
                                OrchFeature.HOST_MAINTENANCE_ENTER,
index 59f311d645ace668f26fd623f875687e0f4bf266..f8f21ac22e095a3957ed6856edb8660a86939d21 100644 (file)
@@ -162,6 +162,7 @@ export class HostsPageHelper extends PageHelper {
   drain(hostname: string) {
     this.getTableCell(this.columnIndex.hostname, hostname, true).click();
     this.clickActionButton('start-drain');
+    cy.wait(1000);
     this.checkLabelExists(hostname, ['_no_schedule'], true);
 
     this.clickTab('cd-host-details', hostname, 'Daemons');
index fd77a4c0b1419be02ffcf5820075f51403cd4992..4bf722df5115d286c871e89a66b1e228c97726eb 100644 (file)
@@ -21,6 +21,7 @@ import { CdValidators } from '~/app/shared/forms/cd-validators';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { Permission } from '~/app/shared/models/permissions';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
 
 @Component({
   selector: 'cd-cephfs-form',
@@ -97,7 +98,8 @@ export class CephfsVolumeFormComponent extends CdForm implements OnInit {
   }
 
   ngOnInit() {
-    this.hostService.list('false').subscribe((resp: object[]) => {
+    const hostContext = new CdTableFetchDataContext(() => undefined);
+    this.hostService.list(hostContext.toParams(), 'false').subscribe((resp: object[]) => {
       const options: SelectOption[] = [];
       _.forEach(resp, (host: object) => {
         if (_.get(host, 'sources.orchestrator', false)) {
index 4490b4e441c9942188ef417a13b755c36a0f661c..964fd7594e79cb01be44f990e245c6825b896f2d 100644 (file)
@@ -5,6 +5,7 @@ import _ from 'lodash';
 import { CephServiceService } from '~/app/shared/api/ceph-service.service';
 import { HostService } from '~/app/shared/api/host.service';
 import { OsdService } from '~/app/shared/api/osd.service';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
 import { CephServiceSpec } from '~/app/shared/models/service.interface';
 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
 import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
@@ -39,7 +40,8 @@ export class CreateClusterReviewComponent implements OnInit {
     let dbDevices = 0;
     let dbDeviceCapacity = 0;
 
-    this.hostService.list('true').subscribe((resp: object[]) => {
+    const hostContext = new CdTableFetchDataContext(() => undefined);
+    this.hostService.list(hostContext.toParams(), 'true').subscribe((resp: object[]) => {
       this.hosts = resp;
       this.hostsCount = this.hosts.length;
       _.forEach(this.hosts, (hostKey) => {
index dc455ca0629aa6a8813449c540cd6bd03b50db34..670a3e00dfe5bbcee3b47c2fd16b5b3560bed637 100644 (file)
@@ -20,6 +20,7 @@ import { OsdService } from '~/app/shared/api/osd.service';
 import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
 import { ActionLabelsI18n, AppConstants, URLVerbs } from '~/app/shared/constants/app.constants';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { DeploymentOptions } from '~/app/shared/models/osd-deployment-options';
 import { Permissions } from '~/app/shared/models/permissions';
@@ -119,7 +120,8 @@ export class CreateClusterComponent implements OnInit, OnDestroy {
 
   onSubmit() {
     if (!this.stepsToSkip['Add Hosts']) {
-      this.hostService.list('false').subscribe((hosts) => {
+      const hostContext = new CdTableFetchDataContext(() => undefined);
+      this.hostService.list(hostContext.toParams(), 'false').subscribe((hosts) => {
         hosts.forEach((host) => {
           const index = host['labels'].indexOf('_no_schedule', 0);
           if (index > -1) {
index 9031ca5f78ad08c992d6c2684dad18b8dc179b54..45622f056998318b55d8885473318121c4a98510 100644 (file)
@@ -12,6 +12,7 @@ import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants
 import { CdForm } from '~/app/shared/forms/cd-form';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 
@@ -57,7 +58,8 @@ export class HostFormComponent extends CdForm implements OnInit {
       this.pageURL = 'hosts';
     }
     this.createForm();
-    this.hostService.list('false').subscribe((resp: any[]) => {
+    const hostContext = new CdTableFetchDataContext(() => undefined);
+    this.hostService.list(hostContext.toParams(), 'false').subscribe((resp: any[]) => {
       this.hostnames = resp.map((host) => {
         return host['hostname'];
       });
index 1aeaef1f95fb3758fbc47dbae7614e6043ff692a..9b997ce2f6bbc548f9752f1c3aeeb42d1900c7a0 100644 (file)
@@ -13,6 +13,9 @@
                 selectionType="single"
                 [searchableObjects]="true"
                 [hasDetails]="hasTableDetails"
+                [serverSide]="true"
+                [count]="count"
+                [maxLimit]="25"
                 (setExpandedRow)="setExpandedRow($event)"
                 (updateSelection)="updateSelection($event)"
                 [toolHeader]="!hideToolHeader">
index 2e76d1f43ed63645d9e1497d9883b76690ac9bd1..43be6e8c758227d52aa6e7fb6dddca8c38920e17 100644 (file)
@@ -1,3 +1,4 @@
+import { HttpHeaders } from '@angular/common/http';
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@@ -45,6 +46,7 @@ describe('HostsComponent', () => {
   let hostListSpy: jasmine.Spy;
   let orchService: OrchestratorService;
   let showForceMaintenanceModal: MockShowForceMaintenanceModal;
+  let headers: HttpHeaders;
 
   const fakeAuthStorageService = {
     getPermissions: () => {
@@ -75,6 +77,7 @@ describe('HostsComponent', () => {
     component = fixture.componentInstance;
     hostListSpy = spyOn(TestBed.inject(HostService), 'list');
     orchService = TestBed.inject(OrchestratorService);
+    headers = new HttpHeaders().set('x-total-count', '10');
   });
 
   it('should create', () => {
@@ -100,11 +103,13 @@ describe('HostsComponent', () => {
           }
         ],
         hostname: hostname,
-        labels: ['foo', 'bar']
+        labels: ['foo', 'bar'],
+        headers: headers
       }
     ];
 
     OrchestratorHelper.mockStatus(false);
+    fixture.detectChanges();
     hostListSpy.and.callFake(() => of(payload));
     fixture.detectChanges();
 
@@ -136,11 +141,13 @@ describe('HostsComponent', () => {
           }
         ],
         hostname: hostname,
-        labels: ['foo', 'bar']
+        labels: ['foo', 'bar'],
+        headers: headers
       }
     ];
 
     OrchestratorHelper.mockStatus(false);
+    fixture.detectChanges();
     hostListSpy.and.callFake(() => of(payload));
     fixture.detectChanges();
 
@@ -173,10 +180,12 @@ describe('HostsComponent', () => {
         hdd_capacity_bytes: 1024,
         flash_count: 4,
         flash_capacity_bytes: 1024,
-        nic_count: 1
+        nic_count: 1,
+        headers: headers
       }
     ];
     OrchestratorHelper.mockStatus(true, features);
+    fixture.detectChanges();
     hostListSpy.and.callFake(() => of(payload));
     fixture.detectChanges();
 
@@ -200,10 +209,12 @@ describe('HostsComponent', () => {
             type: 'osd',
             id: '0'
           }
-        ]
+        ],
+        headers: headers
       }
     ];
     OrchestratorHelper.mockStatus(false);
+    fixture.detectChanges();
     hostListSpy.and.callFake(() => of(payload));
     fixture.detectChanges();
 
@@ -225,10 +236,12 @@ describe('HostsComponent', () => {
             type: 'osd',
             id: '0'
           }
-        ]
+        ],
+        headers: headers
       }
     ];
     OrchestratorHelper.mockStatus(true);
+    fixture.detectChanges();
     hostListSpy.and.callFake(() => of(payload));
     fixture.detectChanges();
 
@@ -260,10 +273,12 @@ describe('HostsComponent', () => {
         hdd_capacity_bytes: undefined,
         flash_count: 4,
         flash_capacity_bytes: undefined,
-        nic_count: 1
+        nic_count: 1,
+        headers: headers
       }
     ];
     OrchestratorHelper.mockStatus(true, features);
+    fixture.detectChanges();
     hostListSpy.and.callFake(() => of(hostPayload));
     fixture.detectChanges();
 
@@ -307,7 +322,10 @@ describe('HostsComponent', () => {
     const fakeHosts = require('./fixtures/host_list_response.json');
 
     beforeEach(() => {
-      hostListSpy.and.callFake(() => of(fakeHosts));
+      let headers = new HttpHeaders().set('x-total-count', '10');
+      headers = headers.set('x-total-count', '10');
+      fakeHosts[0].headers = headers;
+      fakeHosts[1].headers = headers;
     });
 
     const testTableActions = async (
@@ -319,6 +337,9 @@ describe('HostsComponent', () => {
       fixture.detectChanges();
       await fixture.whenStable();
 
+      component.getHosts(new CdTableFetchDataContext(() => undefined));
+      hostListSpy.and.callFake(() => of(fakeHosts));
+      fixture.detectChanges();
       for (const test of tests) {
         if (test.selectRow) {
           component.selection = new CdTableSelection();
index 3bdda8aca3ef4d79ddfd2e35ed241969f105977a..0caeac9f2eb8f797bffc7d58f3dd89fa4bc777d1 100644 (file)
@@ -29,6 +29,7 @@ import { Permissions } from '~/app/shared/models/permissions';
 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
 import { EmptyPipe } from '~/app/shared/pipes/empty.pipe';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { CdTableServerSideService } from '~/app/shared/services/cd-table-server-side.service';
 import { ModalService } from '~/app/shared/services/modal.service';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
@@ -89,6 +90,8 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
   bsModalRef: NgbModalRef;
 
   icons = Icons;
+  private tableContext: CdTableFetchDataContext = null;
+  count = 5;
 
   messages = {
     nonOrchHost: $localize`The feature is disabled because the selected host is not managed by Orchestrator.`
@@ -483,6 +486,12 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
   }
 
   getHosts(context: CdTableFetchDataContext) {
+    if (context !== null) {
+      this.tableContext = context;
+    }
+    if (this.tableContext == null) {
+      this.tableContext = new CdTableFetchDataContext(() => undefined);
+    }
     if (this.isLoadingHosts) {
       return;
     }
@@ -493,11 +502,11 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
         mergeMap((orchStatus) => {
           this.orchStatus = orchStatus;
           const factsAvailable = this.checkHostsFactsAvailable();
-          return this.hostService.list(`${factsAvailable}`);
+          return this.hostService.list(this.tableContext?.toParams(), factsAvailable.toString());
         })
       )
       .subscribe(
-        (hostList) => {
+        (hostList: any[]) => {
           this.hosts = hostList;
           this.hosts.forEach((host: object) => {
             if (host['status'] === '') {
@@ -506,6 +515,11 @@ export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit
           });
           this.transformHostsData();
           this.isLoadingHosts = false;
+          if (this.hosts.length > 0) {
+            this.count = CdTableServerSideService.getCount(hostList[0]);
+          } else {
+            this.count = 0;
+          }
         },
         () => {
           this.isLoadingHosts = false;
index 00276d771bb57b0decd1b46ed9c2ad2c373bc65b..564c364426e9a80c37804bfdf78a96bfe0e68d5b 100644 (file)
@@ -28,6 +28,7 @@ import { CdForm } from '~/app/shared/forms/cd-form';
 import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { CephServiceSpec } from '~/app/shared/models/service.interface';
 import { ModalService } from '~/app/shared/services/modal.service';
@@ -399,7 +400,8 @@ export class ServiceFormComponent extends CdForm implements OnInit {
 
       this.serviceTypes = _.difference(resp, this.hiddenServices).sort();
     });
-    this.hostService.list('false').subscribe((resp: object[]) => {
+    const hostContext = new CdTableFetchDataContext(() => undefined);
+    this.hostService.list(hostContext.toParams(), 'false').subscribe((resp: object[]) => {
       const options: SelectOption[] = [];
       _.forEach(resp, (host: object) => {
         if (_.get(host, 'sources.orchestrator', false)) {
index e4b6476f2c08b49379d9db292542fba40b680e98..52ce44e30ae53ec5dcbd4d5cfb0ea61f24a1c6bf 100644 (file)
@@ -2,6 +2,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/
 import { fakeAsync, TestBed, tick } from '@angular/core/testing';
 
 import { configureTestBed } from '~/testing/unit-test-helper';
+import { CdTableFetchDataContext } from '../models/cd-table-fetch-data-context';
 import { HostService } from './host.service';
 
 describe('HostService', () => {
@@ -27,13 +28,15 @@ describe('HostService', () => {
   });
 
   it('should call list', fakeAsync(() => {
-    let result;
-    service.list('true').subscribe((resp) => (result = resp));
-    const req = httpTesting.expectOne('api/host?facts=true');
+    let result: any[] = [{}, {}];
+    const hostContext = new CdTableFetchDataContext(() => undefined);
+    service.list(hostContext.toParams(), 'true').subscribe((resp) => (result = resp));
+    const req = httpTesting.expectOne('api/host?offset=0&limit=10&search=&sort=+name&facts=true');
     expect(req.request.method).toBe('GET');
-    req.flush(['foo', 'bar']);
+    req.flush([{ foo: 1 }, { bar: 2 }]);
     tick();
-    expect(result).toEqual(['foo', 'bar']);
+    expect(result[0].foo).toEqual(1);
+    expect(result[1].bar).toEqual(2);
   }));
 
   it('should make a GET request on the devices endpoint when requesting devices', () => {
index 7adbd0b104c27b83d1031b83105dd03f5131ef8e..3bb569575836e4aec8d3f41b5fd7bf3ee3d3e824 100644 (file)
@@ -27,11 +27,22 @@ export class HostService extends ApiClient {
     super();
   }
 
-  list(facts: string): Observable<object[]> {
-    return this.http.get<object[]>(this.baseURL, {
-      headers: { Accept: this.getVersionHeaderValue(1, 2) },
-      params: { facts: facts }
-    });
+  list(params: any, facts: string): Observable<object[]> {
+    params = params.set('facts', facts);
+    return this.http
+      .get<object[]>(this.baseURL, {
+        headers: { Accept: this.getVersionHeaderValue(1, 2) },
+        params: params,
+        observe: 'response'
+      })
+      .pipe(
+        map((response: any) => {
+          return response['body'].map((host: any) => {
+            host['headers'] = response.headers;
+            return host;
+          });
+        })
+      );
   }
 
   create(hostname: string, addr: string, labels: string[], status: string) {
index 995dfda51c0f20aa9a01aee1d0237b76f48c1d06..17269efa699a8dd26a4e6dca890da5b88ce61819 100644 (file)
           </span>
 
           <ng-template #serverSideTpl>
-            {{ data?.length || 0 }} <ng-container i18n="X found">found</ng-container> /
-            {{ rowCount }} <ng-container i18n="X total">total</ng-container>
+            <span>
+              {{ data?.length || 0 }} <ng-container i18n="X found">found</ng-container> /
+              {{ rowCount }} <ng-container i18n="X total">total</ng-container>
+            </span>
           </ng-template>
         </div>
         <cd-table-pagination [page]="curPage"
index c8431383e9413ae2b4cd9397659fd3066d74c460..2810663c243e2dbdd5494996ab7c1047ec679f72 100644 (file)
@@ -4238,10 +4238,30 @@ paths:
         name: facts
         schema:
           type: boolean
+      - default: 0
+        in: query
+        name: offset
+        schema:
+          type: integer
+      - default: 5
+        in: query
+        name: limit
+        schema:
+          type: integer
+      - default: ''
+        in: query
+        name: search
+        schema:
+          type: string
+      - default: ''
+        in: query
+        name: sort
+        schema:
+          type: string
       responses:
         '200':
           content:
-            application/vnd.ceph.api.v1.2+json:
+            application/vnd.ceph.api.v1.3+json:
               schema:
                 properties:
                   addr:
@@ -4425,7 +4445,7 @@ paths:
       responses:
         '200':
           content:
-            application/vnd.ceph.api.v1.0+json:
+            application/vnd.ceph.api.v1.2+json:
               type: object
           description: OK
         '400':
index a509f1228e476fe5b70bb9b99d40ab81134c0c73..78221547acc3a065cadb2c8df220dbf9c0198fef 100644 (file)
@@ -4,6 +4,7 @@ This is a minimal implementation of TTL-ed lru_cache function.
 Based on Python 3 functools and backports.functools_lru_cache.
 """
 
+import os
 from collections import OrderedDict
 from functools import wraps
 from threading import RLock
@@ -83,6 +84,10 @@ def ttl_cache(ttl, maxsize=128, typed=False, label: str = ''):
     if typed is not False:
         raise NotImplementedError("typed caching not supported")
 
+    # disable caching while running unit tests
+    if 'UNITTEST' in os.environ:
+        ttl = 0
+
     def decorating_function(function):
         cache_name = label
         if not cache_name:
index a41d33e713c600d19c759de85fbef24532f05e4f..8a86d3b4ba522ca959b83a7bb1c5e62bfe709fcf 100644 (file)
@@ -47,30 +47,31 @@ class HostControllerTest(ControllerTestCase):
 
         def _get_hosts(sources=None):
             if sources == 'ceph':
-                return hosts[0]
+                return [hosts[0]]
             if sources == 'orchestrator':
                 return hosts[1:]
             if sources == 'ceph, orchestrator':
-                return hosts[2]
+                return [hosts[2]]
             return hosts
 
-        mock_get_hosts.side_effect = _get_hosts
-
-        self._get(self.URL_HOST, version=APIVersion(1, 1))
-        self.assertStatus(200)
-        self.assertJsonBody(hosts)
+        with patch_orch(True, hosts=hosts):
+            mock_get_hosts.side_effect = _get_hosts
+            self._get(self.URL_HOST, version=APIVersion(1, 1))
+            self.assertStatus(200)
+            self.assertJsonBody(hosts)
 
-        self._get('{}?sources=ceph'.format(self.URL_HOST), version=APIVersion(1, 1))
-        self.assertStatus(200)
-        self.assertJsonBody(hosts[0])
+            self._get('{}?sources=ceph'.format(self.URL_HOST), version=APIVersion(1, 1))
+            self.assertStatus(200)
+            self.assertJsonBody([hosts[0]])
 
-        self._get('{}?sources=orchestrator'.format(self.URL_HOST), version=APIVersion(1, 1))
-        self.assertStatus(200)
-        self.assertJsonBody(hosts[1:])
+            self._get('{}?sources=orchestrator'.format(self.URL_HOST), version=APIVersion(1, 1))
+            self.assertStatus(200)
+            self.assertJsonBody(hosts[1:])
 
-        self._get('{}?sources=ceph,orchestrator'.format(self.URL_HOST), version=APIVersion(1, 1))
-        self.assertStatus(200)
-        self.assertJsonBody(hosts)
+            self._get('{}?sources=ceph,orchestrator'.format(self.URL_HOST),
+                      version=APIVersion(1, 1))
+            self.assertStatus(200)
+            self.assertJsonBody(hosts)
 
     @mock.patch('dashboard.controllers.host.get_hosts')
     def test_host_list_with_facts(self, mock_get_hosts):
@@ -105,7 +106,9 @@ class HostControllerTest(ControllerTestCase):
                 'orchestrator': False
             },
             'cpu_count': 1,
-            'memory_total_kb': 1024
+            'memory_total_kb': 1024,
+            'services': [],
+            'service_instances': [{'type': 'mon', 'count': 1}]
         }, {
             'hostname': 'host-1',
             'sources': {
@@ -113,31 +116,38 @@ class HostControllerTest(ControllerTestCase):
                 'orchestrator': True
             },
             'cpu_count': 2,
-            'memory_total_kb': 1024
+            'memory_total_kb': 1024,
+            'services': [],
+            'service_instances': [{'type': 'mon', 'count': 1}]
         }]
         # test with orchestrator available
         with patch_orch(True, hosts=hosts_without_facts) as fake_client:
             mock_get_hosts.return_value = hosts_without_facts
-            fake_client.hosts.get_facts.return_value = hosts_facts
+
+            def get_facts_mock(hostname: str):
+                if hostname == 'host-0':
+                    return [hosts_facts[0]]
+                return [hosts_facts[1]]
+            fake_client.hosts.get_facts.side_effect = get_facts_mock
             # test with ?facts=true
-            self._get('{}?facts=true'.format(self.URL_HOST), version=APIVersion(1, 1))
+            self._get('{}?facts=true'.format(self.URL_HOST), version=APIVersion(1, 3))
             self.assertStatus(200)
             self.assertHeader('Content-Type',
-                              APIVersion(1, 2).to_mime_type())
+                              APIVersion(1, 3).to_mime_type())
             self.assertJsonBody(hosts_with_facts)
 
             # test with ?facts=false
-            self._get('{}?facts=false'.format(self.URL_HOST), version=APIVersion(1, 1))
+            self._get('{}?facts=false'.format(self.URL_HOST), version=APIVersion(1, 3))
             self.assertStatus(200)
             self.assertHeader('Content-Type',
-                              APIVersion(1, 2).to_mime_type())
+                              APIVersion(1, 3).to_mime_type())
             self.assertJsonBody(hosts_without_facts)
 
         # test with orchestrator available but orch backend!=cephadm
         with patch_orch(True, missing_features=['get_facts']) as fake_client:
             mock_get_hosts.return_value = hosts_without_facts
             # test with ?facts=true
-            self._get('{}?facts=true'.format(self.URL_HOST), version=APIVersion(1, 1))
+            self._get('{}?facts=true'.format(self.URL_HOST), version=APIVersion(1, 3))
             self.assertStatus(400)
 
         # test with no orchestrator available
@@ -145,14 +155,14 @@ class HostControllerTest(ControllerTestCase):
             mock_get_hosts.return_value = hosts_without_facts
 
             # test with ?facts=true
-            self._get('{}?facts=true'.format(self.URL_HOST), version=APIVersion(1, 1))
+            self._get('{}?facts=true'.format(self.URL_HOST), version=APIVersion(1, 3))
             self.assertStatus(400)
 
             # test with ?facts=false
-            self._get('{}?facts=false'.format(self.URL_HOST), version=APIVersion(1, 1))
+            self._get('{}?facts=false'.format(self.URL_HOST), version=APIVersion(1, 3))
             self.assertStatus(200)
             self.assertHeader('Content-Type',
-                              APIVersion(1, 2).to_mime_type())
+                              APIVersion(1, 3).to_mime_type())
             self.assertJsonBody(hosts_without_facts)
 
     def test_get_1(self):
@@ -472,21 +482,14 @@ class TestHosts(unittest.TestCase):
 
         with patch_orch(True, hosts=orch_hosts):
             hosts = get_hosts()
-            self.assertEqual(len(hosts), 3)
+            self.assertEqual(len(hosts), 2)
             checks = {
-                'localhost': {
-                    'sources': {
-                        'ceph': True,
-                        'orchestrator': False
-                    },
-                    'labels': []
-                },
                 'node1': {
                     'sources': {
-                        'ceph': True,
+                        'ceph': False,
                         'orchestrator': True
                     },
-                    'labels': ['bar', 'foo']
+                    'labels': ['foo', 'bar']
                 },
                 'node2': {
                     'sources': {