--- /dev/null
+
+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)
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">
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';
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',
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,
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();
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();
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);
+ })
+ );
+ }
}
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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' }
+ });
+ }
+}
<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"
<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>
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);
+ }
+}
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',
@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) {
} else {
this.total = this.data;
}
+
+ if (this.dropdownData) {
+ if (this.title == 'Host') {
+ this.dropdownTotalError = this.dropdownData.host.flawed;
+ }
+ }
+ }
+
+ toggleDropdown(): void {
+ this.dropdownToggled = !this.dropdownToggled;
}
}
--- /dev/null
+export enum HardwareNameMapping {
+ memory = 'Memory',
+ storage = 'Drive',
+ processors = 'CPU',
+ network = 'Network',
+ power = 'Power supply',
+ fans = 'Fan module'
+}
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],
MdsSummaryPipe,
OsdSummaryPipe,
OctalToHumanReadablePipe,
- PathPipe
+ PathPipe,
+ PluralizePipe
],
exports: [
ArrayPipe,
MdsSummaryPipe,
OsdSummaryPipe,
OctalToHumanReadablePipe,
- PathPipe
+ PathPipe,
+ PluralizePipe
],
providers: [
ArrayPipe,
--- /dev/null
+import { PluralizePipe } from './pluralize.pipe';
+
+describe('PluralizePipe', () => {
+ it('create an instance', () => {
+ const pipe = new PluralizePipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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';
+ }
+ }
+}
- 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: []
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
--- /dev/null
+
+
+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
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
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']