From: Afreen Misbah Date: Thu, 25 Jun 2026 20:59:06 +0000 (+0530) Subject: mgr/dashboard: add hardware health unit tests and status constants X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=cec298caa50833328c9e6993621c122f89b3e1b9;p=ceph.git mgr/dashboard: add hardware health unit tests and status constants Added Python unit tests and frontend unit tests Signed-off-by: Afreen Misbah --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts index 2e1da7f411b6..dbfe612fde85 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts @@ -14,8 +14,33 @@ import { HardwareService } from '~/app/shared/api/hardware.service'; import { HealthService } from '~/app/shared/api/health.service'; import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { HardwareNameMapping } from '~/app/shared/enum/hardware.enum'; -describe('OverviewStorageCardComponent (Jest)', () => { +const MOCK_HW_SUMMARY = { + total: { + category: { + memory: { total: 8, ok: 8, error: 0 }, + storage: { total: 4, ok: 3, error: 1 }, + processors: { total: 2, ok: 2, error: 0 }, + network: { total: 6, ok: 5, error: 1 }, + power: { total: 4, ok: 4, error: 0 }, + fans: { total: 12, ok: 10, error: 2 } + }, + total: { total: 36, ok: 32, error: 4 } + }, + host: { flawed: 2 } +}; + +const EXPECTED_ICON_MAP: Record = { + memory: 'dataEnrichment', + storage: 'vmdkDisk', + processors: 'chip', + network: 'network1', + power: 'plug', + fans: 'ibmStreamSets' +}; + +describe('OverviewHealthCardComponent', () => { let component: OverviewHealthCardComponent; let fixture: ComponentFixture; @@ -31,15 +56,15 @@ describe('OverviewStorageCardComponent (Jest)', () => { }; const mockAuthStorageService = { - getPermissions: jest.fn(() => ({ configOpt: { read: false } })) + getPermissions: jest.fn(() => ({ configOpt: { read: true } })) }; const mockMgrModuleService = { - getConfig: jest.fn(() => of({ hw_monitoring: false })) + getConfig: jest.fn(() => of({ hw_monitoring: true })) }; const mockHardwareService = { - getSummary: jest.fn(() => of(null)) + getSummary: jest.fn(() => of(MOCK_HW_SUMMARY)) }; const mockHealthService = { @@ -78,4 +103,181 @@ describe('OverviewStorageCardComponent (Jest)', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('hardware sections', () => { + it('should emit hardware rows grouped in pairs', (done) => { + component.sections$.subscribe((sections) => { + expect(sections).not.toBeNull(); + sections!.forEach((section) => { + expect(section.length).toBeLessThanOrEqual(2); + }); + done(); + }); + }); + + it('should limit to 3 groups (6 categories max)', (done) => { + component.sections$.subscribe((sections) => { + expect(sections).not.toBeNull(); + expect(sections!.length).toBeLessThanOrEqual(3); + done(); + }); + }); + + it('should map each row to correct icon from HW_ICON_MAP', (done) => { + component.sections$.subscribe((sections) => { + const rows = sections!.flat(); + rows.forEach((row) => { + expect(row.icon).toBe(EXPECTED_ICON_MAP[row.key]); + }); + done(); + }); + }); + + it('should map each row to correct label from HardwareNameMapping', (done) => { + component.sections$.subscribe((sections) => { + const rows = sections!.flat(); + rows.forEach((row) => { + expect(row.label).toBe( + HardwareNameMapping[row.key as keyof typeof HardwareNameMapping] + ); + }); + done(); + }); + }); + + it('should compute ok and error counts from summary data', (done) => { + component.sections$.subscribe((sections) => { + const rows = sections!.flat(); + const memoryRow = rows.find((r) => r.key === 'memory'); + expect(memoryRow?.ok).toBe(8); + expect(memoryRow?.error).toBe(0); + + const storageRow = rows.find((r) => r.key === 'storage'); + expect(storageRow?.ok).toBe(3); + expect(storageRow?.error).toBe(1); + + const fansRow = rows.find((r) => r.key === 'fans'); + expect(fansRow?.ok).toBe(10); + expect(fansRow?.error).toBe(2); + done(); + }); + }); + + it('should include exactly memory, storage, processors, network, power, fans', (done) => { + component.sections$.subscribe((sections) => { + const keys = sections!.flat().map((r) => r.key); + expect(keys).toEqual(['memory', 'storage', 'processors', 'network', 'power', 'fans']); + done(); + }); + }); + }); +}); + +describe('OverviewHealthCardComponent (hw disabled)', () => { + let component: OverviewHealthCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + OverviewHealthCardComponent, + CommonModule, + ProductiveCardComponent, + SkeletonModule, + ButtonModule, + RouterModule, + ComponentsModule, + LinkModule, + PipesModule + ], + providers: [ + { + provide: SummaryService, + useValue: { + summaryData$: of({ + version: 'ceph version 18.0.0 reef (dev)' + }) + } + }, + { provide: UpgradeService, useValue: { listCached: jest.fn(() => of(null)) } }, + { + provide: AuthStorageService, + useValue: { getPermissions: jest.fn(() => ({ configOpt: { read: true } })) } + }, + { provide: MgrModuleService, useValue: { getConfig: jest.fn(() => of({ hw_monitoring: false })) } }, + { provide: HardwareService, useValue: { getSummary: jest.fn(() => of(null)) } }, + { provide: HealthService, useValue: { getTelemetryStatus: jest.fn(() => of(false)) } }, + provideRouter([]) + ] + }).compileComponents(); + + fixture = TestBed.createComponent(OverviewHealthCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should emit null sections when hw monitoring is disabled', (done) => { + component.sections$.subscribe((sections) => { + expect(sections).toBeNull(); + done(); + }); + }); + + it('should report enabled$ as false', (done) => { + component.enabled$.subscribe((enabled) => { + expect(enabled).toBe(false); + done(); + }); + }); +}); + +describe('OverviewHealthCardComponent (no permissions)', () => { + let component: OverviewHealthCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + OverviewHealthCardComponent, + CommonModule, + ProductiveCardComponent, + SkeletonModule, + ButtonModule, + RouterModule, + ComponentsModule, + LinkModule, + PipesModule + ], + providers: [ + { + provide: SummaryService, + useValue: { + summaryData$: of({ + version: 'ceph version 18.0.0 reef (dev)' + }) + } + }, + { provide: UpgradeService, useValue: { listCached: jest.fn(() => of(null)) } }, + { + provide: AuthStorageService, + useValue: { getPermissions: jest.fn(() => ({ configOpt: { read: false } })) } + }, + { provide: MgrModuleService, useValue: { getConfig: jest.fn(() => of({})) } }, + { provide: HardwareService, useValue: { getSummary: jest.fn(() => of(null)) } }, + { provide: HealthService, useValue: { getTelemetryStatus: jest.fn(() => of(false)) } }, + provideRouter([]) + ] + }).compileComponents(); + + fixture = TestBed.createComponent(OverviewHealthCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should emit false for enabled$ when no configOpt read permission', (done) => { + component.enabled$.subscribe((enabled) => { + expect(enabled).toBe(false); + done(); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/services/hardware.py b/src/pybind/mgr/dashboard/services/hardware.py index 34c8c2cc3faf..04e8d022271c 100644 --- a/src/pybind/mgr/dashboard/services/hardware.py +++ b/src/pybind/mgr/dashboard/services/hardware.py @@ -5,6 +5,9 @@ from typing import Any, Dict, List, Optional from ..exceptions import DashboardException from ..services.orchestrator import OrchClient +STATUS_OK = 'OK' +STATUS_UNKNOWN = 'Unknown' + class HardwareService(object): @@ -25,17 +28,17 @@ class HardwareService(object): def _get_health(component: Any) -> str: if not isinstance(component, dict): - return 'Unknown' - status_val = component.get("status", {}) + return STATUS_UNKNOWN + status_val = component.get('status', {}) if isinstance(status_val, dict): - return status_val.get("health", "Unknown") + return status_val.get('health', STATUS_UNKNOWN) if isinstance(status_val, str): return status_val - return 'Unknown' + return STATUS_UNKNOWN def count_ok(data: dict) -> int: return sum( - _get_health(component) == "OK" + _get_health(component) == STATUS_OK for node in data.values() for system in node.values() for component in system.values() @@ -63,7 +66,7 @@ class HardwareService(object): output['host'].setdefault(host, {'flawed': False}) if not output['host'][host]['flawed']: for system in systems.values(): - if any(_get_health(comp) != 'OK' + if any(_get_health(comp) != STATUS_OK for comp in system.values()): output['host'][host]['flawed'] = True break diff --git a/src/pybind/mgr/dashboard/tests/test_hardware.py b/src/pybind/mgr/dashboard/tests/test_hardware.py new file mode 100644 index 000000000000..2e67c32d11d9 --- /dev/null +++ b/src/pybind/mgr/dashboard/tests/test_hardware.py @@ -0,0 +1,269 @@ +import unittest +from unittest import mock + +from ..exceptions import DashboardException +from ..services.hardware import HardwareService, STATUS_OK, STATUS_UNKNOWN + + +MOCK_HARDWARE_DATA = { + 'memory': { + 'host1': { + 'SystemBoard': { + 'DIMM.Socket.A1': { + 'description': 'DIMM DDR5', + 'status': {'health': 'OK'} + }, + 'DIMM.Socket.A2': { + 'description': 'DIMM DDR5', + 'status': {'health': 'OK'} + } + } + }, + 'host2': { + 'SystemBoard': { + 'DIMM.Socket.A1': { + 'description': 'DIMM DDR5', + 'status': {'health': 'OK'} + } + } + } + }, + 'storage': { + 'host1': { + 'RAID.Integrated.1': { + 'Disk.Bay.0': { + 'description': 'SSD 960GB', + 'status': {'health': 'OK'} + }, + 'Disk.Bay.1': { + 'description': 'SSD 960GB', + 'status': {'health': 'Critical'} + } + } + } + }, + 'processors': { + 'host1': { + 'SystemBoard': { + 'CPU.Socket.1': { + 'description': 'Intel Xeon', + 'status': {'health': 'OK'} + } + } + } + }, + 'network': { + 'host1': { + 'SystemBoard': { + 'NIC.Slot.1': { + 'description': 'Ethernet 25G', + 'status': {'health': 'OK'} + } + } + } + }, + 'power': { + 'host1': { + 'SystemBoard': { + 'PSU.Slot.1': { + 'description': 'PSU 750W', + 'status': {'health': 'OK'} + } + } + } + }, + 'fans': { + 'host1': { + 'SystemBoard': { + 'Fan.Embedded.1': { + 'description': 'System Fan', + 'status': {'health': 'OK'} + } + } + } + } +} + +MOCK_STRING_STATUS_DATA = { + 'host1': { + 'SystemBoard': { + 'DIMM.Socket.A1': { + 'description': 'DIMM DDR5', + 'status': 'OK' + }, + 'DIMM.Socket.A2': { + 'description': 'DIMM DDR5', + 'status': 'Critical' + } + } + } +} + +MOCK_MISSING_STATUS_DATA = { + 'host1': { + 'SystemBoard': { + 'DIMM.Socket.A1': { + 'description': 'DIMM DDR5' + } + } + } +} + +MOCK_NON_DICT_COMPONENT = { + 'host1': { + 'SystemBoard': { + 'DIMM.Socket.A1': 'not-a-dict' + } + } +} + + +class HardwareConstantsTest(unittest.TestCase): + def test_status_ok_value(self): + self.assertEqual(STATUS_OK, 'OK') + + def test_status_unknown_value(self): + self.assertEqual(STATUS_UNKNOWN, 'Unknown') + + +class HardwareValidateCategoriesTest(unittest.TestCase): + def test_none_returns_all(self): + result = HardwareService.validate_categories(None) + self.assertEqual( + result, + ['memory', 'storage', 'processors', 'network', + 'power', 'fans', 'temperatures'] + ) + + def test_single_string_wrapped_in_list(self): + result = HardwareService.validate_categories('memory') + self.assertEqual(result, ['memory']) + + def test_valid_list_passes(self): + result = HardwareService.validate_categories(['memory', 'fans']) + self.assertEqual(result, ['memory', 'fans']) + + def test_invalid_category_raises(self): + with self.assertRaises(DashboardException): + HardwareService.validate_categories(['nonexistent']) + + def test_non_list_type_raises(self): + with self.assertRaises(DashboardException): + HardwareService.validate_categories(123) + + +class HardwareGetSummaryTest(unittest.TestCase): + def _mock_common(self, data_map): + def side_effect(category, hostname=None): + return data_map.get(category, {}) + return side_effect + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_dict_status_health_ok(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common(MOCK_HARDWARE_DATA) + mock_instance.return_value = fake_client + + result = HardwareService.get_summary(categories=['memory']) + cat = result['total']['category']['memory'] + self.assertEqual(cat['total'], 3) + self.assertEqual(cat['ok'], 3) + self.assertEqual(cat['error'], 0) + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_dict_status_health_mixed(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common(MOCK_HARDWARE_DATA) + mock_instance.return_value = fake_client + + result = HardwareService.get_summary(categories=['storage']) + cat = result['total']['category']['storage'] + self.assertEqual(cat['total'], 2) + self.assertEqual(cat['ok'], 1) + self.assertEqual(cat['error'], 1) + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_string_status_format(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common( + {'memory': MOCK_STRING_STATUS_DATA} + ) + mock_instance.return_value = fake_client + + result = HardwareService.get_summary(categories=['memory']) + cat = result['total']['category']['memory'] + self.assertEqual(cat['ok'], 1) + self.assertEqual(cat['error'], 1) + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_missing_status_treated_as_unknown(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common( + {'memory': MOCK_MISSING_STATUS_DATA} + ) + mock_instance.return_value = fake_client + + result = HardwareService.get_summary(categories=['memory']) + cat = result['total']['category']['memory'] + self.assertEqual(cat['ok'], 0) + self.assertEqual(cat['error'], 1) + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_non_dict_component_treated_as_unknown(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common( + {'memory': MOCK_NON_DICT_COMPONENT} + ) + mock_instance.return_value = fake_client + + result = HardwareService.get_summary(categories=['memory']) + cat = result['total']['category']['memory'] + self.assertEqual(cat['ok'], 0) + self.assertEqual(cat['error'], 1) + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_flawed_host_detected(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common(MOCK_HARDWARE_DATA) + mock_instance.return_value = fake_client + + result = HardwareService.get_summary(categories=['storage']) + self.assertEqual(result['host']['flawed'], 1) + self.assertTrue(result['host']['host1']['flawed']) + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_healthy_host_not_flawed(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common(MOCK_HARDWARE_DATA) + mock_instance.return_value = fake_client + + result = HardwareService.get_summary(categories=['memory']) + self.assertEqual(result['host']['flawed'], 0) + self.assertFalse(result['host']['host1']['flawed']) + self.assertFalse(result['host']['host2']['flawed']) + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_all_categories_totals(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common(MOCK_HARDWARE_DATA) + mock_instance.return_value = fake_client + + cats = ['memory', 'storage', 'processors', 'network', 'power', 'fans'] + result = HardwareService.get_summary(categories=cats) + + totals = result['total']['total'] + self.assertEqual(totals['total'], 9) + self.assertEqual(totals['ok'], 8) + self.assertEqual(totals['error'], 1) + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_empty_data_returns_zeros(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common({'memory': {}}) + mock_instance.return_value = fake_client + + result = HardwareService.get_summary(categories=['memory']) + cat = result['total']['category']['memory'] + self.assertEqual(cat['total'], 0) + self.assertEqual(cat['ok'], 0) + self.assertEqual(cat['error'], 0)