From ff4a2810a552aef4384c6db9ba99f889fdee4aae Mon Sep 17 00:00:00 2001 From: Gandalf the Greybeard Date: Wed, 25 Mar 2026 06:18:45 +0000 Subject: [PATCH 1/3] fix: render heading immediately in MetricsPage, before ctxLoading resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heading 'Intel GPU — Metrics' was blocked behind the ctxLoading check, causing the E2E navigation test to timeout when navigating directly to /c/main/intel-gpu/metrics. The K8s.ResourceClasses.useList() hooks in IntelGpuDataContext can take time to resolve when navigating directly to the metrics route (as opposed to via sidebar), causing ctxLoading to remain true beyond the 15s test timeout. Fix: move SectionHeader outside the loading check so it renders immediately. The Loader now appears below the heading while waiting for context to load. Also disable the Refresh button during ctxLoading. Updated unit test to verify heading is visible even when ctxLoading=true. Fixes: headlamp-intel-gpu-plugin#42 Co-Authored-By: Paperclip --- src/components/MetricsPage.test.tsx | 4 +++- src/components/MetricsPage.tsx | 11 +++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/MetricsPage.test.tsx b/src/components/MetricsPage.test.tsx index b92cae8..f775303 100644 --- a/src/components/MetricsPage.test.tsx +++ b/src/components/MetricsPage.test.tsx @@ -106,11 +106,13 @@ describe('MetricsPage', () => { vi.clearAllMocks(); }); - it('shows loader when ctxLoading=true', () => { + it('shows loader when ctxLoading=true but heading is visible immediately', () => { vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true })); // fetchGpuMetrics should never be called in loading state vi.mocked(fetchGpuMetrics).mockResolvedValue(null); render(); + // Heading renders immediately, loader appears below it while waiting for context + expect(screen.getByText('Intel GPU — Metrics')).toBeInTheDocument(); expect(screen.getByTestId('loader')).toHaveTextContent('Loading Intel GPU data...'); }); diff --git a/src/components/MetricsPage.tsx b/src/components/MetricsPage.tsx index 1493590..607bd45 100644 --- a/src/components/MetricsPage.tsx +++ b/src/components/MetricsPage.tsx @@ -230,10 +230,6 @@ export default function MetricsPage() { }; }, [ctxLoading, fetchSeq]); - if (ctxLoading) { - return ; - } - return ( <>
+ {ctxLoading && } + {fetching && !metrics && } -- 2.52.0 From 3228763b905cb3b60e51534336094cae5e9e47fa Mon Sep 17 00:00:00 2001 From: privilegedescalation-engineer Date: Wed, 25 Mar 2026 06:36:39 +0000 Subject: [PATCH 2/3] fix(e2e): use specific heading selectors to avoid strict mode violations Use full page heading text in E2E test selectors instead of generic short terms like /node/i or /pod/i that can match multiple SectionBox headings on the same page. Co-Authored-By: Paperclip --- e2e/intel-gpu.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/intel-gpu.spec.ts b/e2e/intel-gpu.spec.ts index 7b58492..df4b62d 100644 --- a/e2e/intel-gpu.spec.ts +++ b/e2e/intel-gpu.spec.ts @@ -43,7 +43,7 @@ test.describe('Intel GPU plugin smoke tests', () => { test('device plugins page renders or shows empty state', async ({ page }) => { await page.goto('/c/main/intel-gpu/device-plugins'); - await expect(page.getByRole('heading', { name: /device plugin/i })).toBeVisible({ + await expect(page.getByRole('heading', { name: /Intel GPU — Device Plugins/i })).toBeVisible({ timeout: 15_000, }); @@ -66,13 +66,13 @@ test.describe('Intel GPU plugin smoke tests', () => { }); await page.goto('/c/main/intel-gpu/nodes'); - await expect(page.getByRole('heading', { name: /node/i })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: /Intel GPU — Nodes/i })).toBeVisible({ timeout: 15_000 }); await page.goto('/c/main/intel-gpu/pods'); - await expect(page.getByRole('heading', { name: /pod/i })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: /Intel GPU — Pods/i })).toBeVisible({ timeout: 15_000 }); await page.goto('/c/main/intel-gpu/metrics'); - await expect(page.getByRole('heading', { name: /metric/i })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: /Intel GPU — Metrics/i })).toBeVisible({ timeout: 15_000 }); }); test('plugin settings page shows intel-gpu plugin entry', async ({ page }) => { -- 2.52.0 From 15ddba4f799575137b27ff5602f2fe85cf34d399 Mon Sep 17 00:00:00 2001 From: privilegedescalation-engineer Date: Wed, 25 Mar 2026 11:26:01 +0000 Subject: [PATCH 3/3] fix: add request timeout wrapper to prevent E2E test hang Co-Authored-By: Paperclip --- src/api/IntelGpuDataContext.test.tsx | 23 +++++++++++++++++++++++ src/api/IntelGpuDataContext.tsx | 21 ++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/api/IntelGpuDataContext.test.tsx b/src/api/IntelGpuDataContext.test.tsx index 631a395..d9e97bf 100644 --- a/src/api/IntelGpuDataContext.test.tsx +++ b/src/api/IntelGpuDataContext.test.tsx @@ -151,4 +151,27 @@ describe('IntelGpuDataProvider', () => { expect(callCountAfter).toBeGreaterThan(callCountBefore); }); }); + + it('treats a hanging CRD request as unavailable after 2s timeout', async () => { + vi.useFakeTimers(); + const nodeWrapper = { jsonData: {} }; + vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[nodeWrapper], null] as any); + vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[nodeWrapper], null] as any); + vi.mocked(ApiProxy.request) + .mockReturnValueOnce(new Promise(() => {})) + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ items: [] }); + + const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper }); + + expect(result.current.loading).toBe(true); + + vi.advanceTimersByTime(2000); + await act(async () => {}); + expect(result.current.crdAvailable).toBe(false); + expect(result.current.loading).toBe(false); + + vi.useRealTimers(); + }); }); diff --git a/src/api/IntelGpuDataContext.tsx b/src/api/IntelGpuDataContext.tsx index bf724a9..bd4beb8 100644 --- a/src/api/IntelGpuDataContext.tsx +++ b/src/api/IntelGpuDataContext.tsx @@ -69,6 +69,18 @@ export function useIntelGpuContext(): IntelGpuContextValue { // Helpers // --------------------------------------------------------------------------- +const DEFAULT_REQUEST_TIMEOUT_MS = 2_000; + +/** Wraps a promise with a timeout, rejecting if it doesn't settle within ms. */ +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Request timed out after ${ms}ms`)), ms) + ), + ]); +} + /** Extract raw Kubernetes JSON from Headlamp KubeObject wrappers. */ const extractJsonData = (items: unknown[]): unknown[] => items.map(item => @@ -108,8 +120,11 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode } try { // GpuDevicePlugin CRDs — graceful degradation if CRD not installed try { - const pluginList = await ApiProxy.request( - `/apis/${INTEL_DEVICE_PLUGIN_API_GROUP}/${INTEL_DEVICE_PLUGIN_API_VERSION}/gpudeviceplugins` + const pluginList = await withTimeout( + ApiProxy.request( + `/apis/${INTEL_DEVICE_PLUGIN_API_GROUP}/${INTEL_DEVICE_PLUGIN_API_VERSION}/gpudeviceplugins` + ), + DEFAULT_REQUEST_TIMEOUT_MS ); if (!cancelled && isKubeList(pluginList)) { setCrdAvailable(true); @@ -139,7 +154,7 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode } for (const url of pluginPodSelectors) { try { - const list = await ApiProxy.request(url); + const list = await withTimeout(ApiProxy.request(url), DEFAULT_REQUEST_TIMEOUT_MS); if (!cancelled && isKubeList(list)) { const gpuPluginPods = filterIntelGpuPluginPods(list.items); foundPluginPods.push(...gpuPluginPods); -- 2.52.0