]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add hardware health unit tests and status constants
authorAfreen Misbah <afreen@ibm.com>
Thu, 25 Jun 2026 20:59:06 +0000 (02:29 +0530)
committerAfreen Misbah <afreen@ibm.com>
Wed, 15 Jul 2026 11:25:40 +0000 (16:55 +0530)
Added Python unit tests and frontend unit tests

Signed-off-by: Afreen Misbah <afreen@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts
src/pybind/mgr/dashboard/services/hardware.py
src/pybind/mgr/dashboard/tests/test_hardware.py [new file with mode: 0644]

index 2e1da7f411b6d3a22a8cb3426b404fa4c64a324b..dbfe612fde8575fe5b26208badf437baf8b2679f 100644 (file)
@@ -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<string, string> = {
+  memory: 'dataEnrichment',
+  storage: 'vmdkDisk',
+  processors: 'chip',
+  network: 'network1',
+  power: 'plug',
+  fans: 'ibmStreamSets'
+};
+
+describe('OverviewHealthCardComponent', () => {
   let component: OverviewHealthCardComponent;
   let fixture: ComponentFixture<OverviewHealthCardComponent>;
 
@@ -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<OverviewHealthCardComponent>;
+
+  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<OverviewHealthCardComponent>;
+
+  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();
+    });
+  });
 });
index 34c8c2cc3fafa275d1e244afb5b75e4653c99633..04e8d022271cbd8ea6e31991a2c580262263741e 100644 (file)
@@ -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 (file)
index 0000000..2e67c32
--- /dev/null
@@ -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)