diff --git a/src/api/IntelGpuDataContext.test.tsx b/src/api/IntelGpuDataContext.test.tsx new file mode 100644 index 0000000..e144bc6 --- /dev/null +++ b/src/api/IntelGpuDataContext.test.tsx @@ -0,0 +1,154 @@ +import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib'; +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { IntelGpuDataProvider, useIntelGpuContext } from './IntelGpuDataContext'; + +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + K8s: { + ResourceClasses: { + Node: { useList: vi.fn() }, + Pod: { useList: vi.fn() }, + }, + }, + ApiProxy: { request: vi.fn() }, +})); + +// Minimal GPU node fixture +const gpuNodeRaw = { + metadata: { + name: 'gpu-node-1', + uid: 'uid-001', + labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' }, + }, + status: { + capacity: { 'gpu.intel.com/i915': '1' }, + allocatable: { 'gpu.intel.com/i915': '1' }, + }, +}; + +// Minimal GPU plugin CRD fixture +const gpuDevicePluginRaw = { + kind: 'GpuDevicePlugin', + metadata: { name: 'gpu-plugin-default', uid: 'uid-dp-001' }, + spec: {}, +}; + +function makeNodeWrapper(raw: unknown) { + return { jsonData: raw }; +} + +function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe('useIntelGpuContext', () => { + it('throws when used outside provider', () => { + // Suppress React error boundary output + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => renderHook(() => useIntelGpuContext())).toThrow( + 'useIntelGpuContext must be used within an IntelGpuDataProvider' + ); + + consoleError.mockRestore(); + }); +}); + +describe('IntelGpuDataProvider', () => { + it('renders children', async () => { + vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null]); + vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null]); + vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] }); + + render( + +
hello
+
+ ); + + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('exposes loading=true while nodes/pods are null', async () => { + vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([null, null]); + vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([null, null]); + // Keep async request pending forever + vi.mocked(ApiProxy.request).mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper }); + + expect(result.current.loading).toBe(true); + }); + + it('exposes loaded state with GPU nodes once data arrives', async () => { + vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([ + [makeNodeWrapper(gpuNodeRaw)], + null, + ]); + vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null]); + vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] }); + + const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.gpuNodes).toHaveLength(1); + expect(result.current.gpuNodes[0].metadata.name).toBe('gpu-node-1'); + }); + + it('sets crdAvailable=true and populates devicePlugins when ApiProxy returns plugin list', async () => { + vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null]); + vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null]); + + // First call = CRD list, subsequent calls = plugin pod selectors (empty) + vi.mocked(ApiProxy.request) + .mockResolvedValueOnce({ items: [gpuDevicePluginRaw] }) + .mockResolvedValue({ items: [] }); + + const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.crdAvailable).toBe(true); + expect(result.current.devicePlugins).toHaveLength(1); + expect(result.current.devicePlugins[0].metadata.name).toBe('gpu-plugin-default'); + }); + + it('sets crdAvailable=false and does not surface error when ApiProxy throws on CRD request', async () => { + vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null]); + vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null]); + + // First call (CRD endpoint) throws, plugin pod selectors resolve empty + vi.mocked(ApiProxy.request) + .mockRejectedValueOnce(new Error('CRD not found')) + .mockResolvedValue({ items: [] }); + + const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.crdAvailable).toBe(false); + expect(result.current.devicePlugins).toHaveLength(0); + // Inner CRD error should NOT be bubbled up to the top-level error field + expect(result.current.error).toBeNull(); + }); + + it('increments refreshKey and re-runs the effect when refresh() is called', async () => { + vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null]); + vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null]); + vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] }); + + const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + const callCountBefore = vi.mocked(ApiProxy.request).mock.calls.length; + + await act(async () => { + result.current.refresh(); + }); + + await waitFor(() => { + const callCountAfter = vi.mocked(ApiProxy.request).mock.calls.length; + expect(callCountAfter).toBeGreaterThan(callCountBefore); + }); + }); +}); diff --git a/src/components/DevicePluginsPage.test.tsx b/src/components/DevicePluginsPage.test.tsx new file mode 100644 index 0000000..7a77927 --- /dev/null +++ b/src/components/DevicePluginsPage.test.tsx @@ -0,0 +1,175 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext'; +import { GpuDevicePlugin, IntelGpuPod } from '../api/k8s'; +import DevicePluginsPage from './DevicePluginsPage'; + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Loader: ({ title }: { title: string }) =>
{title}
, + SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + SectionHeader: ({ title }: { title: string }) =>

{title}

, + NameValueTable: ({ + rows, + }: { + rows: Array<{ name: React.ReactNode; value: React.ReactNode }>; + }) => ( +
+ {rows.map((r, i) => ( +
+
{r.name}
+
{r.value}
+
+ ))} +
+ ), + SimpleTable: ({ + columns, + data, + }: { + columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>; + data: unknown[]; + }) => ( + + + + {columns.map(c => ( + + ))} + + + + {data.map((item, i) => ( + + {columns.map(c => ( + + ))} + + ))} + +
{c.label}
{c.getter(item)}
+ ), + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + {children} + ), + PercentageBar: () =>
, +})); + +vi.mock('../api/IntelGpuDataContext', () => ({ + useIntelGpuContext: vi.fn(), +})); + +function makeContext(overrides: Partial = {}): IntelGpuContextValue { + return { + devicePlugins: [], + pluginInstalled: false, + gpuNodes: [], + gpuPods: [], + pluginPods: [], + crdAvailable: false, + loading: false, + error: null, + refresh: vi.fn(), + ...overrides, + }; +} + +const samplePlugin: GpuDevicePlugin = { + kind: 'GpuDevicePlugin', + metadata: { + name: 'intel-gpu-plugin', + uid: 'uid-dp-1', + creationTimestamp: '2025-01-01T00:00:00Z', + }, + spec: { + image: 'intel/intel-gpu-plugin:latest', + sharedDevNum: 4, + enableMonitoring: true, + preferredAllocationPolicy: 'balanced', + }, + status: { + desiredNumberScheduled: 3, + numberReady: 3, + }, +}; + +const pluginPod: IntelGpuPod = { + metadata: { + name: 'intel-gpu-plugin-abc12', + namespace: 'kube-system', + uid: 'uid-pp-1', + }, + spec: { nodeName: 'worker-1' }, + status: { + phase: 'Running', + conditions: [{ type: 'Ready', status: 'True' }], + }, +}; + +describe('DevicePluginsPage', () => { + it('shows loader when loading=true', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true })); + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading device plugin data...'); + }); + + it('shows "CRD Not Available" section when crdAvailable=false', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, crdAvailable: false }) + ); + render(); + expect(screen.getByText('CRD Not Available')).toBeInTheDocument(); + expect( + screen.getByText(/GpuDevicePlugin CRD.*is not installed/) + ).toBeInTheDocument(); + }); + + it('shows "No Device Plugins" section when crdAvailable=true but devicePlugins empty', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, crdAvailable: true, devicePlugins: [] }) + ); + render(); + expect(screen.getByText('No Device Plugins')).toBeInTheDocument(); + expect(screen.getByText(/No GpuDevicePlugin resources found/)).toBeInTheDocument(); + }); + + it('shows plugin detail section when crdAvailable=true and devicePlugins present', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ + loading: false, + crdAvailable: true, + devicePlugins: [samplePlugin], + }) + ); + render(); + expect(screen.getByText('GpuDevicePlugin: intel-gpu-plugin')).toBeInTheDocument(); + expect(screen.getByText('intel/intel-gpu-plugin:latest')).toBeInTheDocument(); + }); + + it('shows "Plugin Daemon Pods" table when pluginPods present', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ + loading: false, + crdAvailable: true, + devicePlugins: [samplePlugin], + pluginPods: [pluginPod], + }) + ); + render(); + expect(screen.getByText('Plugin Daemon Pods')).toBeInTheDocument(); + expect(screen.getByText('intel-gpu-plugin-abc12')).toBeInTheDocument(); + }); + + it('shows error section when error is set', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, crdAvailable: true, error: 'fetch error' }) + ); + render(); + expect(screen.getByText('fetch error')).toBeInTheDocument(); + }); +}); diff --git a/src/components/MetricsPage.test.tsx b/src/components/MetricsPage.test.tsx new file mode 100644 index 0000000..6b16f66 --- /dev/null +++ b/src/components/MetricsPage.test.tsx @@ -0,0 +1,213 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext'; +import { fetchGpuMetrics } from '../api/metrics'; +import { GpuMetrics, GpuChipMetrics } from '../api/metrics'; +import MetricsPage from './MetricsPage'; + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Loader: ({ title }: { title: string }) =>
{title}
, + SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + SectionHeader: ({ title }: { title: string }) =>

{title}

, + NameValueTable: ({ + rows, + }: { + rows: Array<{ name: React.ReactNode; value: React.ReactNode }>; + }) => ( +
+ {rows.map((r, i) => ( +
+
{r.name}
+
{r.value}
+
+ ))} +
+ ), + SimpleTable: ({ + columns, + data, + }: { + columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>; + data: unknown[]; + }) => ( + + + + {columns.map(c => ( + + ))} + + + + {data.map((item, i) => ( + + {columns.map(c => ( + + ))} + + ))} + +
{c.label}
{c.getter(item)}
+ ), + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + {children} + ), + PercentageBar: () =>
, +})); + +vi.mock('../api/IntelGpuDataContext', () => ({ + useIntelGpuContext: vi.fn(), +})); + +vi.mock('../api/metrics', () => ({ + fetchGpuMetrics: vi.fn(), + formatWatts: (w: number) => `${w.toFixed(1)} W`, + formatPercent: (used: number, max: number) => + max <= 0 ? '—' : `${Math.round((used / max) * 100)}%`, +})); + +function makeContext(overrides: Partial = {}): IntelGpuContextValue { + return { + devicePlugins: [], + pluginInstalled: false, + gpuNodes: [], + gpuPods: [], + pluginPods: [], + crdAvailable: false, + loading: false, + error: null, + refresh: vi.fn(), + ...overrides, + }; +} + +function makeMetrics(chips: GpuChipMetrics[]): GpuMetrics { + return { + chips, + fetchedAt: new Date('2025-03-21T10:00:00Z').toISOString(), + }; +} + +const sampleChip: GpuChipMetrics = { + nodeName: 'gpu-node-1', + chip: '0000:09:01_0', + instance: '192.168.1.10:9100', + powerWatts: 45.3, + powerMaxWatts: 120.0, +}; + +describe('MetricsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows loader when ctxLoading=true', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true })); + // fetchGpuMetrics should never be called in loading state + vi.mocked(fetchGpuMetrics).mockResolvedValue(null); + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading Intel GPU data...'); + }); + + it('shows "Prometheus Unreachable" section when fetchGpuMetrics returns null', async () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false })); + vi.mocked(fetchGpuMetrics).mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByText('Prometheus Unreachable')).toBeInTheDocument(); + }); + }); + + it('shows "No i915 Metrics in Prometheus" when fetchGpuMetrics returns empty chips', async () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false })); + vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([])); + + render(); + + await waitFor(() => { + expect(screen.getByText('No i915 Metrics in Prometheus')).toBeInTheDocument(); + }); + }); + + it('shows chip cards with node name when fetchGpuMetrics returns chips', async () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false })); + vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([sampleChip])); + + render(); + + await waitFor(() => { + // GpuChipCard title format: "{nodeName} — {chip}" + expect(screen.getByText('gpu-node-1 — 0000:09:01_0')).toBeInTheDocument(); + }); + }); + + it('always renders MetricRequirements section', async () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false })); + vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([])); + + render(); + + // The MetricRequirements section box is titled "Metric Availability" + expect(screen.getByText('Metric Availability')).toBeInTheDocument(); + }); + + it('shows GPU Power Summary section when chips are present', async () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false })); + vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([sampleChip])); + + render(); + + await waitFor(() => { + expect(screen.getByText('GPU Power Summary')).toBeInTheDocument(); + }); + }); + + it('re-triggers fetch when refresh button is clicked', async () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false })); + vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([])); + + render(); + + // Wait for initial fetch to complete + await waitFor(() => { + expect(vi.mocked(fetchGpuMetrics)).toHaveBeenCalled(); + }); + + const callsBefore = vi.mocked(fetchGpuMetrics).mock.calls.length; + + fireEvent.click(screen.getByRole('button', { name: /refresh metrics/i })); + + await waitFor(() => { + expect(vi.mocked(fetchGpuMetrics).mock.calls.length).toBeGreaterThan(callsBefore); + }); + }); + + it('shows "Intel GPU — Metrics" heading', async () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false })); + vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([])); + + render(); + + expect(screen.getByText('Intel GPU — Metrics')).toBeInTheDocument(); + }); + + it('shows power values for chip cards', async () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false })); + vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([sampleChip])); + + render(); + + await waitFor(() => { + // formatWatts mock: "45.3 W" and "120.0 W" + expect(screen.getAllByText(/45\.3 W/).length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/components/NodeDetailSection.test.tsx b/src/components/NodeDetailSection.test.tsx new file mode 100644 index 0000000..08fe04c --- /dev/null +++ b/src/components/NodeDetailSection.test.tsx @@ -0,0 +1,147 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext'; +import { IntelGpuPod } from '../api/k8s'; +import NodeDetailSection from './NodeDetailSection'; + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + NameValueTable: ({ + rows, + }: { + rows: Array<{ name: React.ReactNode; value: React.ReactNode }>; + }) => ( +
+ {rows.map((r, i) => ( +
+
{r.name}
+
{r.value}
+
+ ))} +
+ ), + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock('../api/IntelGpuDataContext', () => ({ + useIntelGpuContext: vi.fn(), +})); + +function makeContext(overrides: Partial = {}): IntelGpuContextValue { + return { + devicePlugins: [], + pluginInstalled: false, + gpuNodes: [], + gpuPods: [], + pluginPods: [], + crdAvailable: false, + loading: false, + error: null, + refresh: vi.fn(), + ...overrides, + }; +} + +// A raw GPU node (matches IntelGpuNode shape) with capacity/allocatable +const gpuNodeRaw = { + kind: 'Node', + metadata: { + name: 'gpu-node-1', + labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' }, + }, + status: { + capacity: { 'gpu.intel.com/i915': '2', cpu: '8' }, + allocatable: { 'gpu.intel.com/i915': '2', cpu: '8' }, + nodeInfo: { + kernelVersion: '5.15.0-generic', + osImage: 'Ubuntu 22.04.3 LTS', + }, + }, +}; + +// A non-GPU node — no labels, no gpu.intel.com capacity +const nonGpuNodeRaw = { + kind: 'Node', + metadata: { + name: 'plain-node-1', + labels: {}, + }, + status: { + capacity: { cpu: '4', memory: '8Gi' }, + allocatable: { cpu: '4', memory: '8Gi' }, + }, +}; + +describe('NodeDetailSection', () => { + it('renders nothing for a non-GPU node (no Intel GPU labels or capacity)', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext()); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing for a non-GPU node passed via jsonData wrapper', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext()); + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders "Intel GPU" section for a GPU node provided via jsonData', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] })); + render(); + expect(screen.getByText('Intel GPU')).toBeInTheDocument(); + }); + + it('renders "Intel GPU" section for a GPU node provided directly', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] })); + render(); + expect(screen.getByText('Intel GPU')).toBeInTheDocument(); + }); + + it('renders capacity and allocatable rows', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] })); + render(); + // GPU (i915) capacity and allocatable rows + expect(screen.getByText('GPU (i915) (capacity)')).toBeInTheDocument(); + expect(screen.getByText('GPU (i915) (allocatable)')).toBeInTheDocument(); + }); + + it('shows "None" for GPU Workload Pods when no pods are on the node and not loading', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] })); + render(); + expect(screen.getByText('None')).toBeInTheDocument(); + }); + + it('shows "Loading…" for GPU Workload Pods when context is loading', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true, gpuPods: [] })); + render(); + expect(screen.getByText('Loading…')).toBeInTheDocument(); + }); + + it('lists pod names when GPU pods are scheduled on the node', () => { + const gpuPod: IntelGpuPod = { + metadata: { name: 'my-gpu-pod', namespace: 'default', uid: 'uid-pod-1' }, + spec: { + nodeName: 'gpu-node-1', + containers: [ + { name: 'main', resources: { requests: { 'gpu.intel.com/i915': '1' } } }, + ], + }, + status: { phase: 'Running' }, + }; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, gpuPods: [gpuPod] }) + ); + render(); + expect(screen.getByText('my-gpu-pod')).toBeInTheDocument(); + }); +}); diff --git a/src/components/NodesPage.test.tsx b/src/components/NodesPage.test.tsx new file mode 100644 index 0000000..5b53b8a --- /dev/null +++ b/src/components/NodesPage.test.tsx @@ -0,0 +1,153 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext'; +import { IntelGpuNode } from '../api/k8s'; +import NodesPage from './NodesPage'; + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Loader: ({ title }: { title: string }) =>
{title}
, + SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + SectionHeader: ({ title }: { title: string }) =>

{title}

, + NameValueTable: ({ + rows, + }: { + rows: Array<{ name: React.ReactNode; value: React.ReactNode }>; + }) => ( +
+ {rows.map((r, i) => ( +
+
{r.name}
+
{r.value}
+
+ ))} +
+ ), + SimpleTable: ({ + columns, + data, + }: { + columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>; + data: unknown[]; + }) => ( + + + + {columns.map(c => ( + + ))} + + + + {data.map((item, i) => ( + + {columns.map(c => ( + + ))} + + ))} + +
{c.label}
{c.getter(item)}
+ ), + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + {children} + ), + PercentageBar: () =>
, +})); + +vi.mock('../api/IntelGpuDataContext', () => ({ + useIntelGpuContext: vi.fn(), +})); + +function makeContext(overrides: Partial = {}): IntelGpuContextValue { + return { + devicePlugins: [], + pluginInstalled: false, + gpuNodes: [], + gpuPods: [], + pluginPods: [], + crdAvailable: false, + loading: false, + error: null, + refresh: vi.fn(), + ...overrides, + }; +} + +const gpuNode: IntelGpuNode = { + metadata: { + name: 'gpu-node-1', + uid: 'uid-001', + labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' }, + creationTimestamp: '2025-01-01T00:00:00Z', + }, + status: { + capacity: { 'gpu.intel.com/i915': '2', cpu: '8' }, + allocatable: { 'gpu.intel.com/i915': '2', cpu: '8' }, + conditions: [{ type: 'Ready', status: 'True' }], + nodeInfo: { + osImage: 'Ubuntu 22.04', + kernelVersion: '5.15.0', + kubeletVersion: 'v1.28.0', + }, + }, +}; + +describe('NodesPage', () => { + it('shows loader when loading=true', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true })); + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading GPU node data...'); + }); + + it('shows "No GPU Nodes Found" when gpuNodes is empty', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuNodes: [] })); + render(); + expect(screen.getByText('No GPU Nodes Found')).toBeInTheDocument(); + }); + + it('shows "GPU Node Summary" section and per-node detail card when gpuNodes present', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, gpuNodes: [gpuNode] }) + ); + render(); + expect(screen.getByText('GPU Node Summary')).toBeInTheDocument(); + // Node name appears in both the summary table and the detail card section header + expect(screen.getAllByText('gpu-node-1').length).toBeGreaterThanOrEqual(1); + }); + + it('renders a detail card for each GPU node', () => { + const secondNode: IntelGpuNode = { + metadata: { + name: 'gpu-node-2', + uid: 'uid-002', + labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' }, + }, + status: { + capacity: { 'gpu.intel.com/i915': '1' }, + allocatable: { 'gpu.intel.com/i915': '1' }, + conditions: [{ type: 'Ready', status: 'True' }], + }, + }; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, gpuNodes: [gpuNode, secondNode] }) + ); + render(); + // Node names appear in both the summary table cell and the detail card heading + expect(screen.getAllByText('gpu-node-1').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('gpu-node-2').length).toBeGreaterThanOrEqual(1); + }); + + it('shows error section when error is set', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, error: 'node fetch failed', gpuNodes: [] }) + ); + render(); + expect(screen.getByText('node fetch failed')).toBeInTheDocument(); + }); +}); diff --git a/src/components/OverviewPage.test.tsx b/src/components/OverviewPage.test.tsx new file mode 100644 index 0000000..05fe420 --- /dev/null +++ b/src/components/OverviewPage.test.tsx @@ -0,0 +1,195 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext'; +import { GpuDevicePlugin, IntelGpuNode, IntelGpuPod } from '../api/k8s'; +import OverviewPage from './OverviewPage'; + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Loader: ({ title }: { title: string }) =>
{title}
, + SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + SectionHeader: ({ title }: { title: string }) =>

{title}

, + NameValueTable: ({ + rows, + }: { + rows: Array<{ name: React.ReactNode; value: React.ReactNode }>; + }) => ( +
+ {rows.map((r, i) => ( +
+
{r.name}
+
{r.value}
+
+ ))} +
+ ), + SimpleTable: ({ + columns, + data, + }: { + columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>; + data: unknown[]; + }) => ( + + + + {columns.map(c => ( + + ))} + + + + {data.map((item, i) => ( + + {columns.map(c => ( + + ))} + + ))} + +
{c.label}
{c.getter(item)}
+ ), + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + {children} + ), + PercentageBar: () =>
, +})); + +vi.mock('../api/IntelGpuDataContext', () => ({ + useIntelGpuContext: vi.fn(), +})); + +function makeContext(overrides: Partial = {}): IntelGpuContextValue { + return { + devicePlugins: [], + pluginInstalled: false, + gpuNodes: [], + gpuPods: [], + pluginPods: [], + crdAvailable: false, + loading: false, + error: null, + refresh: vi.fn(), + ...overrides, + }; +} + +describe('OverviewPage', () => { + it('shows loader when loading=true', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true })); + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading Intel GPU data...'); + }); + + it('shows "Plugin Not Detected" when not loading, no plugin installed, no nodes', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, pluginInstalled: false, gpuNodes: [] }) + ); + render(); + expect(screen.getByText('Plugin Not Detected')).toBeInTheDocument(); + }); + + it('shows error content when error is set', () => { + const errorMsg = 'something went wrong'; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, error: errorMsg, pluginInstalled: true }) + ); + render(); + expect(screen.getByText(errorMsg)).toBeInTheDocument(); + }); + + it('shows "Intel GPU — Overview" heading when gpuNodes present and pluginInstalled', () => { + const node: IntelGpuNode = { + metadata: { + name: 'gpu-node-1', + labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' }, + }, + status: { capacity: { 'gpu.intel.com/i915': '1' }, allocatable: { 'gpu.intel.com/i915': '1' } }, + }; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, pluginInstalled: true, gpuNodes: [node] }) + ); + render(); + expect(screen.getByText('Intel GPU — Overview')).toBeInTheDocument(); + }); + + it('calls refresh() when refresh button is clicked', () => { + const refresh = vi.fn(); + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, pluginInstalled: true, refresh }) + ); + render(); + fireEvent.click(screen.getByRole('button', { name: /refresh intel gpu data/i })); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('shows CRD notice when crdAvailable=false and pluginInstalled=true', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, pluginInstalled: true, crdAvailable: false }) + ); + render(); + expect(screen.getByText('Notice')).toBeInTheDocument(); + expect(screen.getByText(/GpuDevicePlugin CRD not found/)).toBeInTheDocument(); + }); + + it('shows "Device Plugin Status" table when crdAvailable=true and devicePlugins present', () => { + const plugin: GpuDevicePlugin = { + kind: 'GpuDevicePlugin', + metadata: { name: 'my-plugin', uid: 'uid-1' }, + spec: { enableMonitoring: true, sharedDevNum: 2 }, + status: { desiredNumberScheduled: 1, numberReady: 1 }, + }; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ + loading: false, + pluginInstalled: true, + crdAvailable: true, + devicePlugins: [plugin], + }) + ); + render(); + expect(screen.getByText('Device Plugin Status')).toBeInTheDocument(); + expect(screen.getByText('my-plugin')).toBeInTheDocument(); + }); + + it('shows "Plugin Daemon Pods" table when pluginPods present', () => { + const pod: IntelGpuPod = { + metadata: { name: 'plugin-pod-1', namespace: 'kube-system', uid: 'uid-pp-1' }, + spec: { nodeName: 'node-1' }, + status: { phase: 'Running' }, + }; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, pluginInstalled: true, pluginPods: [pod] }) + ); + render(); + expect(screen.getByText('Plugin Daemon Pods')).toBeInTheDocument(); + expect(screen.getByText('plugin-pod-1')).toBeInTheDocument(); + }); + + it('shows "Active GPU Pods" table when running GPU pods exist', () => { + const pod: IntelGpuPod = { + metadata: { name: 'workload-pod-1', namespace: 'default', uid: 'uid-wp-1' }, + spec: { + nodeName: 'gpu-node-1', + containers: [ + { + name: 'main', + resources: { requests: { 'gpu.intel.com/i915': '1' } }, + }, + ], + }, + status: { phase: 'Running' }, + }; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, pluginInstalled: true, gpuPods: [pod] }) + ); + render(); + expect(screen.getByText('Active GPU Pods')).toBeInTheDocument(); + expect(screen.getByText('workload-pod-1')).toBeInTheDocument(); + }); +}); diff --git a/src/components/PodDetailSection.test.tsx b/src/components/PodDetailSection.test.tsx new file mode 100644 index 0000000..ee3ffaa --- /dev/null +++ b/src/components/PodDetailSection.test.tsx @@ -0,0 +1,142 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import PodDetailSection from './PodDetailSection'; + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + NameValueTable: ({ + rows, + }: { + rows: Array<{ name: React.ReactNode; value: React.ReactNode }>; + }) => ( +
+ {rows.map((r, i) => ( +
+
{r.name}
+
{r.value}
+
+ ))} +
+ ), + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + {children} + ), +})); + +// PodDetailSection does NOT use the context — no need to mock IntelGpuDataContext + +// A non-GPU pod (no gpu.intel.com resources) +const nonGpuPodRaw = { + kind: 'Pod', + metadata: { name: 'plain-pod', namespace: 'default' }, + spec: { + containers: [ + { name: 'main', resources: { requests: { cpu: '100m', memory: '128Mi' } } }, + ], + }, + status: { phase: 'Running' }, +}; + +// A GPU-requesting pod +const gpuPodRaw = { + kind: 'Pod', + metadata: { name: 'gpu-workload', namespace: 'default' }, + spec: { + nodeName: 'gpu-node-1', + containers: [ + { + name: 'trainer', + resources: { + requests: { 'gpu.intel.com/i915': '1', cpu: '2' }, + limits: { 'gpu.intel.com/i915': '1', cpu: '2' }, + }, + }, + ], + }, + status: { phase: 'Running' }, +}; + +// A pod with limits only (no requests) +const gpuPodLimitsOnly = { + kind: 'Pod', + metadata: { name: 'limits-only-pod', namespace: 'default' }, + spec: { + containers: [ + { + name: 'app', + resources: { + limits: { 'gpu.intel.com/i915': '1' }, + }, + }, + ], + }, + status: { phase: 'Pending' }, +}; + +describe('PodDetailSection', () => { + it('renders nothing for a non-GPU pod', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing for a non-GPU pod passed via jsonData', () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders "Intel GPU Resources" section for a GPU-requesting pod via jsonData', () => { + render(); + expect(screen.getByText('Intel GPU Resources')).toBeInTheDocument(); + }); + + it('renders "Intel GPU Resources" section for a GPU-requesting pod provided directly', () => { + render(); + expect(screen.getByText('Intel GPU Resources')).toBeInTheDocument(); + }); + + it('shows container GPU resource request rows', () => { + render(); + // Row label: "{containerName} → {resourceName} request" + expect(screen.getByText('trainer → GPU (i915) request')).toBeInTheDocument(); + }); + + it('shows phase status label for Running phase', () => { + render(); + const statusEl = screen.getByText('Running'); + expect(statusEl).toHaveAttribute('data-status', 'success'); + }); + + it('shows phase status label for Pending phase', () => { + render(); + const statusEl = screen.getByText('Pending'); + expect(statusEl).toHaveAttribute('data-status', 'warning'); + }); + + it('still renders when a container has limits only and no requests', () => { + render(); + expect(screen.getByText('Intel GPU Resources')).toBeInTheDocument(); + // limits-only pod: the request row should show '—' since requests key is absent + expect(screen.getByText('app → GPU (i915) request')).toBeInTheDocument(); + }); + + it('shows scheduled node name', () => { + render(); + expect(screen.getByText('gpu-node-1')).toBeInTheDocument(); + }); + + it('shows GPU container count', () => { + render(); + const label = screen.getByText('GPU Containers'); + expect(label).toBeInTheDocument(); + // The value '1' is rendered in the sibling
; verify via parent row + expect(label.closest('div')).toHaveTextContent('1'); + }); +}); diff --git a/src/components/PodsPage.test.tsx b/src/components/PodsPage.test.tsx new file mode 100644 index 0000000..340832c --- /dev/null +++ b/src/components/PodsPage.test.tsx @@ -0,0 +1,176 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext'; +import { IntelGpuPod } from '../api/k8s'; +import PodsPage from './PodsPage'; + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + Loader: ({ title }: { title: string }) =>
{title}
, + SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + SectionHeader: ({ title }: { title: string }) =>

{title}

, + NameValueTable: ({ + rows, + }: { + rows: Array<{ name: React.ReactNode; value: React.ReactNode }>; + }) => ( +
+ {rows.map((r, i) => ( +
+
{r.name}
+
{r.value}
+
+ ))} +
+ ), + SimpleTable: ({ + columns, + data, + }: { + columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>; + data: unknown[]; + }) => ( + + + + {columns.map(c => ( + + ))} + + + + {data.map((item, i) => ( + + {columns.map(c => ( + + ))} + + ))} + +
{c.label}
{c.getter(item)}
+ ), + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + {children} + ), + PercentageBar: () =>
, +})); + +vi.mock('../api/IntelGpuDataContext', () => ({ + useIntelGpuContext: vi.fn(), +})); + +function makeContext(overrides: Partial = {}): IntelGpuContextValue { + return { + devicePlugins: [], + pluginInstalled: false, + gpuNodes: [], + gpuPods: [], + pluginPods: [], + crdAvailable: false, + loading: false, + error: null, + refresh: vi.fn(), + ...overrides, + }; +} + +function makeRunningPod(name: string): IntelGpuPod { + return { + metadata: { name, namespace: 'default', uid: `uid-${name}` }, + spec: { + nodeName: 'gpu-node-1', + containers: [ + { + name: 'main', + resources: { requests: { 'gpu.intel.com/i915': '1' } }, + }, + ], + }, + status: { phase: 'Running' }, + }; +} + +function makePendingPod(name: string): IntelGpuPod { + return { + metadata: { name, namespace: 'default', uid: `uid-${name}` }, + spec: { + containers: [ + { + name: 'main', + resources: { requests: { 'gpu.intel.com/i915': '1' } }, + }, + ], + }, + status: { + phase: 'Pending', + containerStatuses: [ + { + name: 'main', + ready: false, + restartCount: 0, + state: { waiting: { reason: 'Unschedulable' } }, + }, + ], + }, + }; +} + +describe('PodsPage', () => { + it('shows loader when loading=true', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true })); + render(); + expect(screen.getByTestId('loader')).toHaveTextContent('Loading GPU pod data...'); + }); + + it('shows "No GPU Pods Found" when gpuPods is empty', () => { + vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] })); + render(); + expect(screen.getByText('No GPU Pods Found')).toBeInTheDocument(); + }); + + it('shows summary section with total count when pods present', () => { + const pods = [makeRunningPod('pod-1'), makeRunningPod('pod-2')]; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, gpuPods: pods }) + ); + render(); + expect(screen.getByText('Summary')).toBeInTheDocument(); + // 'Total GPU Pods' label is present; '2' appears in multiple places (row value + status label) + expect(screen.getByText('Total GPU Pods')).toBeInTheDocument(); + expect(screen.getAllByText('2').length).toBeGreaterThanOrEqual(1); + }); + + it('shows "Attention: Pending GPU Pods" section when pending pods exist', () => { + const pods = [makePendingPod('pending-pod-1')]; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, gpuPods: pods }) + ); + render(); + expect(screen.getByText('Attention: Pending GPU Pods')).toBeInTheDocument(); + // Pod name appears in both the main "All GPU Pods" table and the pending attention table + expect(screen.getAllByText('pending-pod-1').length).toBeGreaterThanOrEqual(1); + }); + + it('shows error section when error is set', () => { + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, error: 'pod list failed', gpuPods: [] }) + ); + render(); + expect(screen.getByText('pod list failed')).toBeInTheDocument(); + }); + + it('shows "All GPU Pods" table with pod name when pods present', () => { + const pods = [makeRunningPod('my-workload')]; + vi.mocked(useIntelGpuContext).mockReturnValue( + makeContext({ loading: false, gpuPods: pods }) + ); + render(); + expect(screen.getByText('All GPU Pods')).toBeInTheDocument(); + expect(screen.getByText('my-workload')).toBeInTheDocument(); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 3a3739f..32ad385 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -6,5 +6,8 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], exclude: ['e2e/**', 'node_modules/**'], + env: { + NODE_ENV: 'test', + }, }, });