]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: allow getting fresh inventory data from the orchestrator 41387/head
authorKiefer Chang <kiefer.chang@suse.com>
Mon, 27 Apr 2020 08:08:22 +0000 (16:08 +0800)
committerVolker Theile <vtheile@suse.com>
Tue, 18 May 2021 15:13:53 +0000 (17:13 +0200)
When there is a device change, a `ceph orch device ls --refresh` command
needs to be called so the orchestrator can invalidate its cache and
refresh all devices on all nodes. Currently, the call is asynchronous and
there is no way to determine is a refresh is done or not.

To allow doing a refresh in the Dashboard:
- The inventory device list is periodically updated with cached data.
- If the user clicks the refresh button, a refresh call is sent to the
  orchestrator. Thus if there are device changes, it will be revealed soon
  because of the periodical update.

Fixes: https://tracker.ceph.com/issues/44803
Signed-off-by: Kiefer Chang <kiefer.chang@suse.com>
(cherry picked from commit 1147d6dca04fe6e45b385b549db37775fce54edd)

Conflicts:
src/pybind/mgr/dashboard/controllers/orchestrator.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts

src/pybind/mgr/dashboard/controllers/orchestrator.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/inventory/inventory.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts

index a7fda9f85081c7706c2c77143f7320e11d6a30fc..39820086667f6a38269ce80d4054b3c47111e921 100644 (file)
@@ -11,7 +11,7 @@ from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.exception import handle_orchestrator_error
 from ..services.orchestrator import OrchClient
-from ..tools import TaskManager, wraps
+from ..tools import TaskManager, str_to_bool, wraps
 
 
 def get_device_osd_map():
@@ -102,10 +102,13 @@ class Orchestrator(RESTController):
 class OrchestratorInventory(RESTController):
 
     @raise_if_no_orchestrator
-    def list(self, hostname=None):
+    def list(self, hostname=None, refresh=None):
         orch = OrchClient.instance()
         hosts = [hostname] if hostname else None
-        inventory_hosts = [host.to_json() for host in orch.inventory.list(hosts)]
+        do_refresh = False
+        if refresh is not None:
+            do_refresh = str_to_bool(refresh)
+        inventory_hosts = [host.to_json() for host in orch.inventory.list(hosts, do_refresh)]
         device_osd_map = get_device_osd_map()
         for inventory_host in inventory_hosts:
             host_osds = device_osd_map.get(inventory_host['name'])
index 360aac4cea54043c15eb2ddf4baef4184fe70605..04d5cecd5c7ab4c67c6996dfdb4e65a6cef1eec7 100644 (file)
@@ -50,13 +50,17 @@ describe('InventoryComponent', () => {
   describe('after ngOnInit', () => {
     it('should load devices', () => {
       fixture.detectChanges();
-      expect(orchService.inventoryDeviceList).toHaveBeenCalledWith(undefined);
-    });
+      expect(orchService.inventoryDeviceList).toHaveBeenNthCalledWith(1, undefined, false);
+      component.refresh(); // click refresh button
+      expect(orchService.inventoryDeviceList).toHaveBeenNthCalledWith(2, undefined, true);
 
-    it('should load devices for a host', () => {
-      component.hostname = 'host0';
+      const newHost = 'host0';
+      component.hostname = newHost;
       fixture.detectChanges();
-      expect(orchService.inventoryDeviceList).toHaveBeenCalledWith('host0');
+      component.ngOnChanges();
+      expect(orchService.inventoryDeviceList).toHaveBeenNthCalledWith(3, newHost, false);
+      component.refresh(); // click refresh button
+      expect(orchService.inventoryDeviceList).toHaveBeenNthCalledWith(4, newHost, true);
     });
   });
 });
index d70c70ecd2ea445a37b9cbd5ef22c0d900a463e7..21bc4e509b3b306ac3ecabc459c2fb59f387e726 100644 (file)
@@ -1,4 +1,6 @@
-import { Component, Input, OnChanges, OnInit } from '@angular/core';
+import { Component, Input, NgZone, OnChanges, OnDestroy, OnInit } from '@angular/core';
+
+import { Subscription, timer as observableTimer } from 'rxjs';
 
 import { OrchestratorService } from '../../../shared/api/orchestrator.service';
 import { Icons } from '../../../shared/enum/icons.enum';
@@ -9,10 +11,14 @@ import { InventoryDevice } from './inventory-devices/inventory-device.model';
   templateUrl: './inventory.component.html',
   styleUrls: ['./inventory.component.scss']
 })
-export class InventoryComponent implements OnChanges, OnInit {
+export class InventoryComponent implements OnChanges, OnInit, OnDestroy {
   // Display inventory page only for this hostname, ignore to display all.
   @Input() hostname?: string;
 
+  private reloadSubscriber: Subscription;
+  private reloadInterval = 5000;
+  private firstRefresh = true;
+
   icons = Icons;
 
   hasOrchestrator = false;
@@ -20,30 +26,48 @@ export class InventoryComponent implements OnChanges, OnInit {
 
   devices: Array<InventoryDevice> = [];
 
-  constructor(private orchService: OrchestratorService) {}
+  constructor(private orchService: OrchestratorService, private ngZone: NgZone) {}
 
   ngOnInit() {
     this.orchService.status().subscribe((status) => {
       this.hasOrchestrator = status.available;
       this.showDocPanel = !status.available;
       if (status.available) {
-        this.getInventory();
+        // Create a timer to get cached inventory from the orchestrator.
+        // Do not ask the orchestrator frequently to refresh its cache data because it's expensive.
+        this.ngZone.runOutsideAngular(() => {
+          // start after first pass because the embedded table calls refresh at init.
+          this.reloadSubscriber = observableTimer(
+            this.reloadInterval,
+            this.reloadInterval
+          ).subscribe(() => {
+            this.ngZone.run(() => {
+              this.getInventory(false);
+            });
+          });
+        });
       }
     });
   }
 
+  ngOnDestroy() {
+    if (this.reloadSubscriber) {
+      this.reloadSubscriber.unsubscribe();
+    }
+  }
+
   ngOnChanges() {
     if (this.hasOrchestrator) {
       this.devices = [];
-      this.getInventory();
+      this.getInventory(false);
     }
   }
 
-  getInventory() {
+  getInventory(refresh: boolean) {
     if (this.hostname === '') {
       return;
     }
-    this.orchService.inventoryDeviceList(this.hostname).subscribe(
+    this.orchService.inventoryDeviceList(this.hostname, refresh).subscribe(
       (devices: InventoryDevice[]) => {
         this.devices = devices;
       },
@@ -54,6 +78,9 @@ export class InventoryComponent implements OnChanges, OnInit {
   }
 
   refresh() {
-    this.getInventory();
+    // Make the first reload (triggered by table) use cached data, and
+    // the remaining reloads (triggered by users) ask orchestrator to refresh inventory.
+    this.getInventory(!this.firstRefresh);
+    this.firstRefresh = false;
   }
 }
index e8ca64cd39773a2dd3803aac5867f81dbdde3ce8..bbf64918a6e5338e1dae60d4497177f3a7bb06dd 100644 (file)
@@ -33,16 +33,31 @@ describe('OrchestratorService', () => {
     expect(req.request.method).toBe('GET');
   });
 
-  it('should call inventoryList', () => {
-    service.inventoryList().subscribe();
-    const req = httpTesting.expectOne(`${apiPath}/inventory`);
-    expect(req.request.method).toBe('GET');
-  });
-
-  it('should call inventoryList with a host', () => {
-    const host = 'host0';
-    service.inventoryList(host).subscribe();
-    const req = httpTesting.expectOne(`${apiPath}/inventory?hostname=${host}`);
-    expect(req.request.method).toBe('GET');
+  it('should call inventoryList with arguments', () => {
+    const inventoryPath = `${apiPath}/inventory`;
+    const tests: { args: any[]; expectedUrl: any }[] = [
+      {
+        args: [],
+        expectedUrl: inventoryPath
+      },
+      {
+        args: ['host0'],
+        expectedUrl: `${inventoryPath}?hostname=host0`
+      },
+      {
+        args: [undefined, true],
+        expectedUrl: `${inventoryPath}?refresh=true`
+      },
+      {
+        args: ['host0', true],
+        expectedUrl: `${inventoryPath}?hostname=host0&refresh=true`
+      }
+    ];
+
+    for (const test of tests) {
+      service.inventoryList(...test.args).subscribe();
+      const req = httpTesting.expectOne(test.expectedUrl);
+      expect(req.request.method).toBe('GET');
+    }
   });
 });
index cea8d4bccd93bfc7d408ff33b888cbfb6e308511..27cf964b4510adcf42e5288fae03bae847e5e468 100644 (file)
@@ -29,13 +29,19 @@ export class OrchestratorService {
     });
   }
 
-  inventoryList(hostname?: string): Observable<InventoryHost[]> {
-    const options = hostname ? { params: new HttpParams().set('hostname', hostname) } : {};
-    return this.http.get<InventoryHost[]>(`${this.url}/inventory`, options);
+  inventoryList(hostname?: string, refresh?: boolean): Observable<InventoryHost[]> {
+    let params = new HttpParams();
+    if (hostname) {
+      params = params.append('hostname', hostname);
+    }
+    if (refresh) {
+      params = params.append('refresh', _.toString(refresh));
+    }
+    return this.http.get<InventoryHost[]>(`${this.url}/inventory`, { params: params });
   }
 
-  inventoryDeviceList(hostname?: string): Observable<InventoryDevice[]> {
-    return this.inventoryList(hostname).pipe(
+  inventoryDeviceList(hostname?: string, refresh?: boolean): Observable<InventoryDevice[]> {
+    return this.inventoryList(hostname, refresh).pipe(
       mergeMap((hosts: InventoryHost[]) => {
         const devices = _.flatMap(hosts, (host) => {
           return host.devices.map((device) => {