Compare commits

..

1 Commits

Author SHA1 Message Date
Gandalf the Greybeard ff4a2810a5 fix: render heading immediately in MetricsPage, before ctxLoading resolves
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 <noreply@paperclip.ing>
2026-03-25 06:18:45 +00:00
5 changed files with 12 additions and 46 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ test.describe('Intel GPU plugin smoke tests', () => {
}); });
await page.goto('/c/main/intel-gpu/nodes'); await page.goto('/c/main/intel-gpu/nodes');
await expect(page.getByRole('heading', { name: /intel gpu.*nodes/i })).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole('heading', { name: /node/i })).toBeVisible({ timeout: 15_000 });
await page.goto('/c/main/intel-gpu/pods'); 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: /pod/i })).toBeVisible({ timeout: 15_000 });
-17
View File
@@ -151,21 +151,4 @@ describe('IntelGpuDataProvider', () => {
expect(callCountAfter).toBeGreaterThan(callCountBefore); expect(callCountAfter).toBeGreaterThan(callCountBefore);
}); });
}); });
it('treats a hanging CRD request as unavailable after 2s timeout', async () => {
vi.useFakeTimers();
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
vi.mocked(ApiProxy.request).mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.loading).toBe(false);
expect(result.current.crdAvailable).toBe(false);
vi.useRealTimers();
});
}); });
+3 -21
View File
@@ -69,18 +69,6 @@ export function useIntelGpuContext(): IntelGpuContextValue {
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DEFAULT_REQUEST_TIMEOUT_MS = 2_000;
/** Wraps a promise with a timeout, rejecting if it doesn't settle within ms. */
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Request timed out after ${ms}ms`)), ms)
),
]);
}
/** Extract raw Kubernetes JSON from Headlamp KubeObject wrappers. */ /** Extract raw Kubernetes JSON from Headlamp KubeObject wrappers. */
const extractJsonData = (items: unknown[]): unknown[] => const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item => items.map(item =>
@@ -120,11 +108,8 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
try { try {
// GpuDevicePlugin CRDs — graceful degradation if CRD not installed // GpuDevicePlugin CRDs — graceful degradation if CRD not installed
try { try {
const pluginList = await withTimeout( const pluginList = await ApiProxy.request(
ApiProxy.request( `/apis/${INTEL_DEVICE_PLUGIN_API_GROUP}/${INTEL_DEVICE_PLUGIN_API_VERSION}/gpudeviceplugins`
`/apis/${INTEL_DEVICE_PLUGIN_API_GROUP}/${INTEL_DEVICE_PLUGIN_API_VERSION}/gpudeviceplugins`
),
DEFAULT_REQUEST_TIMEOUT_MS
); );
if (!cancelled && isKubeList(pluginList)) { if (!cancelled && isKubeList(pluginList)) {
setCrdAvailable(true); setCrdAvailable(true);
@@ -154,10 +139,7 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
for (const url of pluginPodSelectors) { for (const url of pluginPodSelectors) {
try { try {
const list = await withTimeout( const list = await ApiProxy.request(url);
ApiProxy.request(url),
DEFAULT_REQUEST_TIMEOUT_MS
);
if (!cancelled && isKubeList(list)) { if (!cancelled && isKubeList(list)) {
const gpuPluginPods = filterIntelGpuPluginPods(list.items); const gpuPluginPods = filterIntelGpuPluginPods(list.items);
foundPluginPods.push(...gpuPluginPods); foundPluginPods.push(...gpuPluginPods);
+3 -1
View File
@@ -106,11 +106,13 @@ describe('MetricsPage', () => {
vi.clearAllMocks(); 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 })); vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true }));
// fetchGpuMetrics should never be called in loading state // fetchGpuMetrics should never be called in loading state
vi.mocked(fetchGpuMetrics).mockResolvedValue(null); vi.mocked(fetchGpuMetrics).mockResolvedValue(null);
render(<MetricsPage />); render(<MetricsPage />);
// 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...'); expect(screen.getByTestId('loader')).toHaveTextContent('Loading Intel GPU data...');
}); });
+5 -6
View File
@@ -230,10 +230,6 @@ export default function MetricsPage() {
}; };
}, [ctxLoading, fetchSeq]); }, [ctxLoading, fetchSeq]);
if (ctxLoading) {
return <Loader title="Loading Intel GPU data..." />;
}
return ( return (
<> <>
<div <div
@@ -247,7 +243,7 @@ export default function MetricsPage() {
<SectionHeader title="Intel GPU — Metrics" /> <SectionHeader title="Intel GPU — Metrics" />
<button <button
onClick={() => void doFetch()} onClick={() => void doFetch()}
disabled={fetching} disabled={fetching || ctxLoading}
aria-label="Refresh metrics" aria-label="Refresh metrics"
style={{ style={{
padding: '6px 16px', padding: '6px 16px',
@@ -255,15 +251,18 @@ export default function MetricsPage() {
color: 'var(--mui-palette-primary-main, #0071c5)', color: 'var(--mui-palette-primary-main, #0071c5)',
border: '1px solid var(--mui-palette-primary-main, #0071c5)', border: '1px solid var(--mui-palette-primary-main, #0071c5)',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: fetching || ctxLoading ? 'not-allowed' : 'pointer',
fontSize: '13px', fontSize: '13px',
fontWeight: 500, fontWeight: 500,
opacity: fetching || ctxLoading ? 0.6 : 1,
}} }}
> >
{fetching ? 'Refreshing…' : 'Refresh'} {fetching ? 'Refreshing…' : 'Refresh'}
</button> </button>
</div> </div>
{ctxLoading && <Loader title="Loading Intel GPU data..." />}
<MetricRequirements /> <MetricRequirements />
{fetching && !metrics && <Loader title="Querying Prometheus for GPU metrics..." />} {fetching && !metrics && <Loader title="Querying Prometheus for GPU metrics..." />}