]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add hardware status summary 55464/head
authorPedro Gonzalez Gomez <pegonzal@redhat.com>
Tue, 6 Feb 2024 11:40:11 +0000 (12:40 +0100)
committerPedro Gonzalez Gomez <pegonzal@redhat.com>
Sun, 3 Mar 2024 19:46:05 +0000 (20:46 +0100)
On the landing page of the Dashboard, add the hardware status summary

Fixes:https://tracker.ceph.com/issues/64329
Signed-off-by: Pedro Gonzalez Gomez <pegonzal@redhat.com>
15 files changed:
src/pybind/mgr/dashboard/controllers/hardware.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-row/card-row.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-row/card-row.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-row/card-row.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pluralize.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pluralize.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/hardware.py [new file with mode: 0644]
src/pybind/mgr/dashboard/services/orchestrator.py

diff --git a/src/pybind/mgr/dashboard/controllers/hardware.py b/src/pybind/mgr/dashboard/controllers/hardware.py
new file mode 100644 (file)
index 0000000..72550ed
--- /dev/null
@@ -0,0 +1,21 @@
+
+from typing import List, Optional
+
+from ..services.hardware import HardwareService
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
+from ._version import APIVersion
+
+
+@APIRouter('/hardware')
+@APIDoc("Hardware management API", "Hardware")
+class Hardware(RESTController):
+
+    @RESTController.Collection('GET', version=APIVersion.EXPERIMENTAL)
+    @EndpointDoc("Retrieve a summary of the hardware health status")
+    def summary(self, categories: Optional[List[str]] = None, hostname: Optional[List[str]] = None):
+        """
+        Get the health status of as many hardware categories, or all of them if none is given
+        :param categories: The hardware type, all of them by default
+        :param hostname: The host to retrieve from, all of them by default
+        """
+        return HardwareService.get_summary(categories, hostname)
index 4c290746b45b1cdbdad8e9b5cc0d9cd2d1957f86..bad69c50122b699aef812e150379c02dabd987d3 100644 (file)
@@ -56,7 +56,9 @@
                      link="/hosts"
                      title="Host"
                      summaryType="simplified"
-                     *ngIf="healthData.hosts != null"></cd-card-row>
+                     *ngIf="healthData.hosts != null"
+                     [dropdownData]="(isHardwareEnabled$ | async) && (hardwareSummary$ | async)">
+        </cd-card-row>
         <!-- Monitors -->
         <cd-card-row [data]="healthData.mon_status.monmap.mons.length"
                      link="/monitor"
                 </ul>
               </ng-template>
 
-              <div class="d-flex flex-row">
+              <div class="d-flex flex-row col-md-3 ms-4">
                 <i *ngIf="healthData.health?.status"
                    [ngClass]="[healthData.health.status | healthIcon, icons.large2x]"
                    [ngStyle]="healthData.health.status | healthColor"
                       i18n>Cluster</span>
               </div>
             </div>
+
+            <div class="d-flex flex-column col-md-3">
+              <div *ngIf="hasHardwareError"
+                   class="d-flex flex-row">
+                <i class="text-danger"
+                   [ngClass]="[icons.danger, icons.large2x]"></i>
+                <span class="ms-2 mt-n1 lead"
+                      i18n>Hardware</span>
+              </div>
+            </div>
             <section class="footer alerts"
                      *ngIf="isAlertmanagerConfigured && prometheusAlertService.alerts.length">
               <div class="d-flex flex-wrap ms-4 me-4 mb-3 mt-3">
index 3c44bd36a8905d7b90db9716d550dc9beaab260f..853eed2d695f2c3a68e270650f73c7493448a529 100644 (file)
@@ -1,8 +1,8 @@
 import { Component, OnDestroy, OnInit } from '@angular/core';
 
 import _ from 'lodash';
-import { Observable, Subscription } from 'rxjs';
-import { take } from 'rxjs/operators';
+import { BehaviorSubject, Observable, Subscription, of } from 'rxjs';
+import { switchMap, take } from 'rxjs/operators';
 
 import { HealthService } from '~/app/shared/api/health.service';
 import { OsdService } from '~/app/shared/api/osd.service';
@@ -24,6 +24,7 @@ import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.s
 import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
 import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
 import { AlertClass } from '~/app/shared/enum/health-icon.enum';
+import { HardwareService } from '~/app/shared/api/hardware.service';
 
 @Component({
   selector: 'cd-dashboard-v3',
@@ -69,6 +70,12 @@ export class DashboardV3Component extends PrometheusListHelper implements OnInit
   telemetryEnabled: boolean;
   telemetryURL = 'https://telemetry-public.ceph.com/';
   origin = window.location.origin;
+  hardwareHealth: any;
+  hardwareEnabled: boolean = false;
+  hasHardwareError: boolean = false;
+  isHardwareEnabled$: Observable<boolean>;
+  hardwareSummary$: Observable<any>;
+  hardwareSubject = new BehaviorSubject<any>([]);
 
   constructor(
     private summaryService: SummaryService,
@@ -80,7 +87,8 @@ export class DashboardV3Component extends PrometheusListHelper implements OnInit
     public prometheusService: PrometheusService,
     private mgrModuleService: MgrModuleService,
     private refreshIntervalService: RefreshIntervalService,
-    public prometheusAlertService: PrometheusAlertService
+    public prometheusAlertService: PrometheusAlertService,
+    private hardwareService: HardwareService
   ) {
     super(prometheusService);
     this.permissions = this.authStorageService.getPermissions();
@@ -89,9 +97,21 @@ export class DashboardV3Component extends PrometheusListHelper implements OnInit
 
   ngOnInit() {
     super.ngOnInit();
+    this.isHardwareEnabled$ = this.getHardwareConfig();
+    this.hardwareSummary$ = this.hardwareSubject.pipe(
+      switchMap(() =>
+        this.hardwareService.getSummary().pipe(
+          switchMap((data: any) => {
+            this.hasHardwareError = data.host.flawed;
+            return of(data);
+          })
+        )
+      )
+    );
     this.interval = this.refreshIntervalService.intervalData$.subscribe(() => {
       this.getHealth();
       this.getCapacityCardData();
+      if (this.hardwareEnabled) this.hardwareSubject.next([]);
     });
     this.getPrometheusData(this.prometheusService.lastHourDateObject);
     this.getDetailsCardData();
@@ -163,4 +183,13 @@ export class DashboardV3Component extends PrometheusListHelper implements OnInit
   trackByFn(index: any) {
     return index;
   }
+
+  getHardwareConfig(): Observable<any> {
+    return this.mgrModuleService.getConfig('cephadm').pipe(
+      switchMap((resp: any) => {
+        this.hardwareEnabled = resp?.hw_monitoring;
+        return of(resp?.hw_monitoring);
+      })
+    );
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.spec.ts
new file mode 100644 (file)
index 0000000..b9deac3
--- /dev/null
@@ -0,0 +1,23 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HardwareService } from './hardware.service';
+
+describe('HardwareService', () => {
+  let service: HardwareService;
+
+  configureTestBed({
+    providers: [HardwareService],
+    imports: [HttpClientTestingModule]
+  });
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    service = TestBed.inject(HardwareService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.ts
new file mode 100644 (file)
index 0000000..3238493
--- /dev/null
@@ -0,0 +1,18 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class HardwareService {
+  baseURL = 'api/hardware';
+
+  constructor(private http: HttpClient) {}
+
+  getSummary(category: string[] = []): any {
+    return this.http.get<any>(`${this.baseURL}/summary`, {
+      params: { categories: category },
+      headers: { Accept: 'application/vnd.ceph.api.v0.1+json' }
+    });
+  }
+}
index 4e193717252c3f3b2a32f6b6bc4a0ed5e610d8b2..a1e3e6b0b371180a871fa247c56e25ce49312803 100644 (file)
@@ -1,6 +1,6 @@
 <hr>
 <li class="list-group-item">
-  <div class="d-flex pl-1 pb-2 pt-2">
+  <div class="d-flex pl-1 pb-2 pt-2 position-relative">
     <div class="ms-4 me-auto">
       <a [routerLink]="link"
          *ngIf="link && total > 0; else noLinkTitle"
@@ -12,7 +12,7 @@
         <ng-template ngPluralCase="other">{{ title }}s</ng-template>
       </a>
     </div>
-    <span class="me-3">
+    <span class="me-4">
       <ng-container [ngSwitch]="summaryType">
         <ng-container *ngSwitchCase="'iscsi'">
           <ng-container *ngTemplateOutlet="iscsiSummary"></ng-container>
         </ng-container>
       </ng-container>
     </span>
+    <span *ngIf="dropdownData && dropdownData.total.total.total > 0"
+          class="position-absolute end-0 me-2">
+      <a (click)="toggleDropdown()"
+         class="dropdown-toggle"
+         [attr.aria-expanded]="dropdownToggled"
+         aria-controls="row-dropdwon"
+         role="button"></a>
+    </span>
   </div>
 </li>
 
+<div *ngIf="dropdownToggled">
+  <hr>
+  <ng-container *ngTemplateOutlet="dropdownTemplate"></ng-container>
+</div>
+
 <ng-template #defaultSummary>
   <span *ngIf="data.success || data.categoryPgAmount?.clean || (data.success === 0 && data.total === 0)">
     <span *ngIf="data.success || (data.success === 0 && data.total === 0)">
 </ng-template>
 
 <ng-template #simplifiedSummary>
-  <span>
+  <span *ngIf="!dropdownTotalError else showErrorNum">
     {{ data }}
     <i class="text-success"
        [ngClass]="[icons.success]"></i>
   </span>
+  <ng-template #showErrorNum>
+    <span *ngIf="data - dropdownTotalError  > 0">
+      {{ data - dropdownTotalError  }}
+    <i class="text-success"
+       [ngClass]="[icons.success]"></i>
+    </span>
+    <span>
+      {{ dropdownTotalError  }}
+      <i class="text-danger"
+         [ngClass]="[icons.danger]"></i>
+    </span>
+  </ng-template>
 </ng-template>
 
 <ng-template #noLinkTitle>
     <ng-template ngPluralCase="other">{{ title }}s</ng-template>
   </span>
 </ng-template>
+
+<ng-template #dropdownTemplate>
+  <ng-container *ngFor="let data of dropdownData?.total.category | keyvalue">
+    <li class="list-group-item">
+      <div class="d-flex pb-2 pt-2">
+        <div class="ms-5 me-auto">
+          <span *ngIf="data.value.total"
+                [ngPlural]="data.value.total"
+                i18n>
+              {{ data.value.total }}
+            <ng-template ngPluralCase="=0">{{ hwNames[data.key] }}</ng-template>
+            <ng-template ngPluralCase="=1">{{ hwNames[data.key] }}</ng-template>
+            <ng-template ngPluralCase="other">{{ hwNames[data.key] | pluralize }}</ng-template>
+          </span>
+        </div>
+        <span [ngClass]="data.value.error ? 'me-2' : 'me-4'">
+          {{ data.value.ok }}
+          <i class="text-success"
+             *ngIf="data.value.ok"
+             [ngClass]="[icons.success]">
+          </i>
+        </span>
+        <span *ngIf="data.value.error"
+              class="me-4 ms-2">
+              {{ data.value.error }}
+          <i class="text-danger"
+             [ngClass]="[icons.danger]">
+          </i>
+        </span>
+      </div>
+    </li>
+  </ng-container>
+</ng-template>
index 29901b832d3a5d6a2cb0ba4e2fc621e311ea0d9b..f93d6313aac31e6a3320fd7bb3691d37d68b3e48 100644 (file)
@@ -2,3 +2,18 @@
   border: 0;
   font-size: 14px;
 }
+
+a.dropdown-toggle {
+  &::after {
+    border: 0;
+    content: '\f054';
+    font-family: 'ForkAwesome';
+    font-size: 1rem;
+    margin-top: 0.15rem;
+    transition: transform 0.3s ease-in-out;
+  }
+
+  &[aria-expanded='true']::after {
+    transform: rotate(90deg);
+  }
+}
index 90c939160eb91e47387c59c6b93a5a63000fdebb..d977e905f531c412c948f4b3354cf5301e6c979c 100644 (file)
@@ -1,5 +1,6 @@
 import { Component, Input, OnChanges } from '@angular/core';
 import { Icons } from '~/app/shared/enum/icons.enum';
+import { HardwareNameMapping } from '~/app/shared/enum/hardware.enum';
 
 @Component({
   selector: 'cd-card-row',
@@ -19,8 +20,14 @@ export class CardRowComponent implements OnChanges {
   @Input()
   summaryType = 'default';
 
+  @Input()
+  dropdownData: any;
+
+  hwNames = HardwareNameMapping;
   icons = Icons;
   total: number;
+  dropdownTotalError: number = 0;
+  dropdownToggled: boolean = false;
 
   ngOnChanges(): void {
     if (this.data.total || this.data.total === 0) {
@@ -30,5 +37,15 @@ export class CardRowComponent implements OnChanges {
     } else {
       this.total = this.data;
     }
+
+    if (this.dropdownData) {
+      if (this.title == 'Host') {
+        this.dropdownTotalError = this.dropdownData.host.flawed;
+      }
+    }
+  }
+
+  toggleDropdown(): void {
+    this.dropdownToggled = !this.dropdownToggled;
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts
new file mode 100644 (file)
index 0000000..7956dfa
--- /dev/null
@@ -0,0 +1,8 @@
+export enum HardwareNameMapping {
+  memory = 'Memory',
+  storage = 'Drive',
+  processors = 'CPU',
+  network = 'Network',
+  power = 'Power supply',
+  fans = 'Fan module'
+}
index b5267aa71216d2c516baa8cd9a6708cd341ae9ec..53f8f9f309f4956d858acb83e800703560fc5c61 100755 (executable)
@@ -37,6 +37,7 @@ import { TruncatePipe } from './truncate.pipe';
 import { UpperFirstPipe } from './upper-first.pipe';
 import { OctalToHumanReadablePipe } from './octal-to-human-readable.pipe';
 import { PathPipe } from './path.pipe';
+import { PluralizePipe } from './pluralize.pipe';
 
 @NgModule({
   imports: [CommonModule],
@@ -76,7 +77,8 @@ import { PathPipe } from './path.pipe';
     MdsSummaryPipe,
     OsdSummaryPipe,
     OctalToHumanReadablePipe,
-    PathPipe
+    PathPipe,
+    PluralizePipe
   ],
   exports: [
     ArrayPipe,
@@ -114,7 +116,8 @@ import { PathPipe } from './path.pipe';
     MdsSummaryPipe,
     OsdSummaryPipe,
     OctalToHumanReadablePipe,
-    PathPipe
+    PathPipe,
+    PluralizePipe
   ],
   providers: [
     ArrayPipe,
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pluralize.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pluralize.pipe.spec.ts
new file mode 100644 (file)
index 0000000..72ba020
--- /dev/null
@@ -0,0 +1,8 @@
+import { PluralizePipe } from './pluralize.pipe';
+
+describe('PluralizePipe', () => {
+  it('create an instance', () => {
+    const pipe = new PluralizePipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pluralize.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pluralize.pipe.ts
new file mode 100644 (file)
index 0000000..c4035ad
--- /dev/null
@@ -0,0 +1,14 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'pluralize'
+})
+export class PluralizePipe implements PipeTransform {
+  transform(value: string): string {
+    if (value.endsWith('y')) {
+      return value.slice(0, -1) + 'ies';
+    } else {
+      return value + 's';
+    }
+  }
+}
index ad4d379fffbbee61e4a9410b4e5d8a6c3cfcc2fc..23d8a66013b648c6b8c6da0c11e42c753579941b 100644 (file)
@@ -4715,6 +4715,43 @@ paths:
       - jwt: []
       tags:
       - Grafana
+  /api/hardware/summary:
+    get:
+      description: "\n        Get the health status of as many hardware categories,\
+        \ or all of them if none is given\n        :param categories: The hardware\
+        \ type, all of them by default\n        :param hostname: The host to retrieve\
+        \ from, all of them by default\n        "
+      parameters:
+      - allowEmptyValue: true
+        in: query
+        name: categories
+        schema:
+          type: string
+      - allowEmptyValue: true
+        in: query
+        name: hostname
+        schema:
+          type: string
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v0.1+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Retrieve a summary of the hardware health status
+      tags:
+      - Hardware
   /api/health/full:
     get:
       parameters: []
@@ -14029,6 +14066,8 @@ tags:
   name: FeatureTogglesEndpoint
 - description: Grafana Management API
   name: Grafana
+- description: Hardware management API
+  name: Hardware
 - description: Display Detailed Cluster health Status
   name: Health
 - description: Get Host Details
diff --git a/src/pybind/mgr/dashboard/services/hardware.py b/src/pybind/mgr/dashboard/services/hardware.py
new file mode 100644 (file)
index 0000000..df22664
--- /dev/null
@@ -0,0 +1,75 @@
+
+
+from typing import Any, Dict, List, Optional
+
+from ..exceptions import DashboardException
+from ..services.orchestrator import OrchClient
+
+
+class HardwareService(object):
+
+    @staticmethod
+    def get_summary(categories: Optional[List[str]] = None,
+                    hostname: Optional[List[str]] = None):
+        total_count = {'total': 0, 'ok': 0, 'error': 0}
+
+        output: Dict[str, Any] = {
+            'total': {
+                'category': {},
+                'total': {}
+            },
+            'host': {
+                'flawed': 0
+            }
+        }
+
+        categories = HardwareService.validate_categories(categories)
+
+        orch_hardware_instance = OrchClient.instance().hardware
+        for category in categories:
+            data = orch_hardware_instance.common(category, hostname)
+            category_total = {
+                'total': sum(len(items) for items in data.values()),
+                'ok': sum(item['status']['health'] == 'OK' for items in data.values()
+                          for item in items.values()),
+                'error': 0
+            }
+
+            for host, items in data.items():
+                output['host'].setdefault(host, {'flawed': False})
+                if not output['host'][host]['flawed']:
+                    output['host'][host]['flawed'] = any(
+                        item['status']['health'] != 'OK' for item in items.values())
+
+            category_total['error'] = category_total['total'] - category_total['ok']
+            output['total']['category'].setdefault(category, {})
+            output['total']['category'][category] = category_total
+
+            total_count['total'] += category_total['total']
+            total_count['ok'] += category_total['ok']
+            total_count['error'] += category_total['error']
+
+        output['total']['total'] = total_count
+
+        output['host']['flawed'] = sum(1 for host in output['host']
+                                       if host != 'flawed' and output['host'][host]['flawed'])
+
+        return output
+
+    @staticmethod
+    def validate_categories(categories: Optional[List[str]]) -> List[str]:
+        categories_list = ['memory', 'storage', 'processors',
+                           'network', 'power', 'fans']
+
+        if isinstance(categories, str):
+            categories = [categories]
+        elif categories is None:
+            categories = categories_list
+        elif not isinstance(categories, list):
+            raise DashboardException(msg=f'{categories} is not a list',
+                                     component='Hardware')
+        if not all(item in categories_list for item in categories):
+            raise DashboardException(msg=f'Invalid category, there is no {categories}',
+                                     component='Hardware')
+
+        return categories
index e49ab80bfc5dc359b94138cb581f7d6a63e7bceb..97776dec335c3cf0bcbcc76256005a64e07fdd6f 100644 (file)
@@ -200,6 +200,13 @@ class UpgradeManager(ResourceManager):
         return self.api.upgrade_stop()
 
 
+class HardwareManager(ResourceManager):
+
+    @wait_api_result
+    def common(self, category: str, hostname: Optional[List[str]] = None) -> str:
+        return self.api.node_proxy_common(category, hostname=hostname)
+
+
 class OrchClient(object):
 
     _instance = None
@@ -220,6 +227,7 @@ class OrchClient(object):
         self.osds = OsdManager(self.api)
         self.daemons = DaemonManager(self.api)
         self.upgrades = UpgradeManager(self.api)
+        self.hardware = HardwareManager(self.api)
 
     def available(self, features: Optional[List[str]] = None) -> bool:
         available = self.status()['available']