From 1c65b9ef3dd6b139098e14541a8f4933f3c20c3c Mon Sep 17 00:00:00 2001 From: Pere Diaz Bou Date: Thu, 22 Jun 2023 11:22:05 +0200 Subject: [PATCH] mgr/dashboard: paginate hosts Signed-off-by: Pere Diaz Bou Fixes: https://tracker.ceph.com/issues/56513 (cherry picked from commit 720bb10e30bb7f686eb528d19c112bcf7ae7765c) --- src/pybind/mgr/dashboard/controllers/host.py | 101 +++++------ .../frontend/cypress/e2e/cluster/hosts.po.ts | 1 + .../cephfs-form/cephfs-form.component.ts | 168 ++++++++++++++++++ .../create-cluster-review.component.ts | 4 +- .../create-cluster.component.ts | 32 ++-- .../hosts/host-form/host-form.component.ts | 4 +- .../ceph/cluster/hosts/hosts.component.html | 3 + .../cluster/hosts/hosts.component.spec.ts | 35 +++- .../app/ceph/cluster/hosts/hosts.component.ts | 18 +- .../service-form/service-form.component.ts | 4 +- .../src/app/shared/api/host.service.spec.ts | 13 +- .../src/app/shared/api/host.service.ts | 21 ++- .../datatable/table/table.component.html | 6 +- src/pybind/mgr/dashboard/openapi.yaml | 24 ++- src/pybind/mgr/dashboard/plugins/ttl_cache.py | 5 + src/pybind/mgr/dashboard/tests/test_host.py | 77 ++++---- 16 files changed, 381 insertions(+), 135 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.ts diff --git a/src/pybind/mgr/dashboard/controllers/host.py b/src/pybind/mgr/dashboard/controllers/host.py index 9faaa519202c6..812b9c035ae3d 100644 --- a/src/pybind/mgr/dashboard/controllers/host.py +++ b/src/pybind/mgr/dashboard/controllers/host.py @@ -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, diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/hosts.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/hosts.po.ts index 59f311d645ace..f8f21ac22e095 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/hosts.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/hosts.po.ts @@ -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'); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.ts new file mode 100644 index 0000000000000..4bf722df5115d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.ts @@ -0,0 +1,168 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import _ from 'lodash'; + +import { NgbNav, NgbTooltip, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; +import { merge, Observable, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators'; + +import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { HostService } from '~/app/shared/api/host.service'; +import { OrchestratorService } from '~/app/shared/api/orchestrator.service'; +import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; +import { SelectOption } from '~/app/shared/components/select/select-option.model'; +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { Icons } from '~/app/shared/enum/icons.enum'; +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 { 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', + templateUrl: './cephfs-form.component.html', + styleUrls: ['./cephfs-form.component.scss'] +}) +export class CephfsVolumeFormComponent extends CdForm implements OnInit { + @ViewChild('crushInfoTabs') crushInfoTabs: NgbNav; + @ViewChild('crushDeletionBtn') crushDeletionBtn: NgbTooltip; + @ViewChild('ecpInfoTabs') ecpInfoTabs: NgbNav; + @ViewChild('ecpDeletionBtn') ecpDeletionBtn: NgbTooltip; + @ViewChild(NgbTypeahead, { static: false }) + typeahead: NgbTypeahead; + + labelFocus = new Subject(); + labelClick = new Subject(); + + orchStatus$: Observable; + + permission: Permission; + form: CdFormGroup; + action: string; + resource: string; + editing: boolean; + icons = Icons; + hosts: any; + labels: string[]; + hasOrchestrator: boolean; + + constructor( + private router: Router, + private taskWrapperService: TaskWrapperService, + private orchService: OrchestratorService, + private formBuilder: CdFormBuilder, + public actionLabels: ActionLabelsI18n, + private hostService: HostService, + private cephfsService: CephfsService + ) { + super(); + this.editing = this.router.url.startsWith(`/pool/${URLVerbs.EDIT}`); + this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.CREATE; + this.resource = $localize`volume`; + this.hosts = { + options: [], + messages: new SelectMessages({ + empty: $localize`There are no hosts.`, + filter: $localize`Filter hosts` + }) + }; + this.createForm(); + } + + private createForm() { + this.orchService.status().subscribe((status) => { + this.hasOrchestrator = status.available; + }); + this.form = this.formBuilder.group({ + name: new FormControl('', { + validators: [Validators.pattern(/^[.A-Za-z0-9_/-]+$/), Validators.required] + }), + placement: ['hosts'], + hosts: [[]], + label: [ + null, + [ + CdValidators.requiredIf({ + placement: 'label', + unmanaged: false + }) + ] + ], + unmanaged: [false] + }); + } + + ngOnInit() { + 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)) { + const option = new SelectOption(false, _.get(host, 'hostname'), ''); + options.push(option); + } + }); + this.hosts.options = [...options]; + }); + this.hostService.getLabels().subscribe((resp: string[]) => { + this.labels = resp; + }); + this.orchStatus$ = this.orchService.status(); + } + + searchLabels = (text$: Observable) => { + return merge( + text$.pipe(debounceTime(200), distinctUntilChanged()), + this.labelFocus, + this.labelClick.pipe(filter(() => !this.typeahead.isPopupOpen())) + ).pipe( + map((value) => + this.labels + .filter((label: string) => label.toLowerCase().indexOf(value.toLowerCase()) > -1) + .slice(0, 10) + ) + ); + }; + + submit() { + let values = this.form.getRawValue(); + const serviceSpec: object = { + placement: {}, + unmanaged: values['unmanaged'] + }; + switch (values['placement']) { + case 'hosts': + if (values['hosts'].length > 0) { + serviceSpec['placement']['hosts'] = values['hosts']; + } + break; + case 'label': + serviceSpec['placement']['label'] = values['label']; + break; + } + + const volumeName = this.form.get('name').value; + const self = this; + let taskUrl = `cephfs/${URLVerbs.CREATE}`; + this.taskWrapperService + .wrapTaskAroundCall({ + task: new FinishedTask(taskUrl, { + volumeName: volumeName + }), + call: this.cephfsService.create(this.form.get('name').value, serviceSpec) + }) + .subscribe({ + error() { + self.form.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.router.navigate(['cephfs']); + } + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.ts index 4490b4e441c99..964fd7594e79c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-review.component.ts @@ -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) => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts index dc455ca0629aa..002ec50d0e3ab 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts @@ -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) { @@ -128,20 +130,20 @@ export class CreateClusterComponent implements OnInit, OnDestroy { } }); forkJoin(this.observables) - .pipe( - finalize(() => - this.clusterService.updateStatus('POST_INSTALLED').subscribe(() => { - this.notificationService.show( - NotificationType.success, - $localize`Cluster expansion was successful` - ); - this.router.navigate(['/dashboard']); - }) - ) - ) - .subscribe({ - error: (error) => error.preventDefault() - }); + .pipe( + finalize(() => + this.clusterService.updateStatus('POST_INSTALLED').subscribe(() => { + this.notificationService.show( + NotificationType.success, + $localize`Cluster expansion was successful` + ); + this.router.navigate(['/dashboard']); + }) + ) + ) + .subscribe({ + error: (error) => error.preventDefault() + }); }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts index 9031ca5f78ad0..45622f0569983 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts @@ -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']; }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html index 1aeaef1f95fb3..9b997ce2f6bbc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.html @@ -13,6 +13,9 @@ selectionType="single" [searchableObjects]="true" [hasDetails]="hasTableDetails" + [serverSide]="true" + [count]="count" + [maxLimit]="25" (setExpandedRow)="setExpandedRow($event)" (updateSelection)="updateSelection($event)" [toolHeader]="!hideToolHeader"> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts index 2e76d1f43ed63..43be6e8c75822 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.spec.ts @@ -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(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts index 3bdda8aca3ef4..0caeac9f2eb8f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts @@ -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; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts index 5ae2dfa50b4c1..c8fc9dd01bf7b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts @@ -18,6 +18,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 { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; @@ -370,7 +371,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)) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts index e4b6476f2c08b..52ce44e30ae53 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts @@ -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', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts index 7adbd0b104c27..3bb569575836e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts @@ -27,11 +27,22 @@ export class HostService extends ApiClient { super(); } - list(facts: string): Observable { - return this.http.get(this.baseURL, { - headers: { Accept: this.getVersionHeaderValue(1, 2) }, - params: { facts: facts } - }); + list(params: any, facts: string): Observable { + params = params.set('facts', facts); + return this.http + .get(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) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html index fc7b9f6e54ea2..0eac512ef3fe3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -230,8 +230,10 @@ - {{ data?.length || 0 }} found / - {{ rowCount }} total + + {{ data?.length || 0 }} found / + {{ rowCount }} total +