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
11 changed files with 33 additions and 12047 deletions
-2
View File
@@ -16,5 +16,3 @@ jobs:
dual-approval: dual-approval:
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
secrets: inherit secrets: inherit
with:
pr_number: ${{ github.event.pull_request.number }}
+3 -3
View File
@@ -1,4 +1,4 @@
version: "1.1.0" version: "1.0.0"
name: headlamp-intel-gpu name: headlamp-intel-gpu
displayName: Intel GPU displayName: Intel GPU
description: >- description: >-
@@ -99,7 +99,7 @@ screenshots:
url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/03-metrics.svg url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/03-metrics.svg
annotations: annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v1.1.0/intel-gpu-1.1.0.tar.gz" headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v1.0.0/intel-gpu-1.0.0.tar.gz"
headlamp/plugin/archive-checksum: sha256:e212381f38c331383604b06f6552997fcba5c8b42a3bd828e3b43ed3e5028448 headlamp/plugin/archive-checksum: sha256:93d6c531e7c12440c9625138f0645fc0c3521b574d0089492759699b324943f0
headlamp/plugin/version-compat: ">=0.20.0" headlamp/plugin/version-compat: ">=0.20.0"
headlamp/plugin/distro-compat: "in-cluster,web,app" headlamp/plugin/distro-compat: "in-cluster,web,app"
-12
View File
@@ -1,12 +0,0 @@
---
# RBAC for the GitHub Actions CI runner to manage E2E Headlamp instances.
# CI-only test fixture — NOT for production use.
#
# This file is a REFERENCE ONLY. The canonical manifest lives in:
# privilegedescalation/infra/base/rbac/e2e-ci-runner-headlamp-rbac.yaml
#
# The infra repo is managed by Flux GitOps and is the source of truth.
# Do not apply this file directly — it is kept here for developer reference only.
#
# E2E resources run in `privilegedescalation-dev` — nothing persists beyond a test run.
# RBAC is managed via Flux from privilegedescalation/infra — do not apply manually.
+13 -21
View File
@@ -19,18 +19,16 @@ test.describe('Intel GPU plugin smoke tests', () => {
// Should navigate to the overview route // Should navigate to the overview route
await expect(page).toHaveURL(/\/intel-gpu$/); await expect(page).toHaveURL(/\/intel-gpu$/);
await expect( await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible();
page.locator('main').getByRole('heading', { name: 'Intel GPU — Overview' })
).toBeVisible();
}); });
test('overview page renders GPU device list or empty state', async ({ page }) => { test('overview page renders GPU device list or empty state', async ({ page }) => {
await page.goto('/c/main/intel-gpu'); await page.goto('/c/main/intel-gpu');
// Overview heading should be present // Overview heading should be present
await expect( await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible({
page.locator('main').getByRole('heading', { name: 'Intel GPU — Overview' }) timeout: 15_000,
).toBeVisible({ timeout: 15_000 }); });
// Either a populated table/list or an empty-state indicator must be visible // Either a populated table/list or an empty-state indicator must be visible
const hasTable = await page.locator('table').first().isVisible().catch(() => false); const hasTable = await page.locator('table').first().isVisible().catch(() => false);
@@ -45,9 +43,9 @@ test.describe('Intel GPU plugin smoke tests', () => {
test('device plugins page renders or shows empty state', async ({ page }) => { test('device plugins page renders or shows empty state', async ({ page }) => {
await page.goto('/c/main/intel-gpu/device-plugins'); await page.goto('/c/main/intel-gpu/device-plugins');
await expect( await expect(page.getByRole('heading', { name: /device plugin/i })).toBeVisible({
page.locator('main').getByRole('heading', { name: 'Intel GPU — Device Plugins' }) timeout: 15_000,
).toBeVisible({ timeout: 15_000 }); });
const hasTable = await page.locator('table').first().isVisible().catch(() => false); const hasTable = await page.locator('table').first().isVisible().catch(() => false);
const hasEmptyState = await page const hasEmptyState = await page
@@ -63,24 +61,18 @@ test.describe('Intel GPU plugin smoke tests', () => {
// not after clicking the parent entry from the overview. Test route // not after clicking the parent entry from the overview. Test route
// accessibility via direct navigation — each route must render its heading. // accessibility via direct navigation — each route must render its heading.
await page.goto('/c/main/intel-gpu'); await page.goto('/c/main/intel-gpu');
await expect( await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible({
page.locator('main').getByRole('heading', { name: 'Intel GPU — Overview' }) timeout: 15_000,
).toBeVisible({ timeout: 15_000 }); });
await page.goto('/c/main/intel-gpu/nodes'); await page.goto('/c/main/intel-gpu/nodes');
await expect( await expect(page.getByRole('heading', { name: /node/i })).toBeVisible({ timeout: 15_000 });
page.locator('main').getByRole('heading', { name: 'Intel GPU — Nodes' })
).toBeVisible({ timeout: 15_000 });
await page.goto('/c/main/intel-gpu/pods'); await page.goto('/c/main/intel-gpu/pods');
await expect( await expect(page.getByRole('heading', { name: /pod/i })).toBeVisible({ timeout: 15_000 });
page.locator('main').getByRole('heading', { name: 'Intel GPU — Pods' })
).toBeVisible({ timeout: 15_000 });
await page.goto('/c/main/intel-gpu/metrics'); await page.goto('/c/main/intel-gpu/metrics');
await expect( await expect(page.getByRole('heading', { name: /metric/i })).toBeVisible({ timeout: 15_000 });
page.locator('main').getByRole('heading', { name: 'Intel GPU — Metrics' })
).toBeVisible({ timeout: 15_000 });
}); });
test('plugin settings page shows intel-gpu plugin entry', async ({ page }) => { test('plugin settings page shows intel-gpu plugin entry', async ({ page }) => {
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "intel-gpu", "name": "intel-gpu",
"version": "1.1.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "intel-gpu", "name": "intel-gpu",
"version": "1.1.0", "version": "1.0.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0", "@kinvolk/headlamp-plugin": "^0.13.0",
@@ -11600,9 +11600,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
+2 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "intel-gpu", "name": "intel-gpu",
"version": "1.1.0", "version": "1.0.0",
"description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring", "description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -36,18 +36,14 @@
"@testing-library/jest-dom": "^6.4.8", "@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"eslint": "^8.57.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"prettier": "^3.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^5.3.0", "react-router-dom": "^5.3.0",
"typescript": "^5.6.2",
"vitest": "^3.0.5" "vitest": "^3.0.5"
}, },
"overrides": { "overrides": {
"tar": "^7.5.11", "tar": "^7.5.11",
"undici": "^7.24.3", "undici": "^7.24.3"
"lodash": ">=4.18.0"
} }
} }
-11945
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -5,18 +5,16 @@
# a ConfigMap volume mount. No custom Docker images — the plugin is built # a ConfigMap volume mount. No custom Docker images — the plugin is built
# in CI and injected as a ConfigMap. # in CI and injected as a ConfigMap.
# #
# E2E resources are deployed to the `headlamp-plugins-e2e` namespace. Nothing # E2E resources are deployed to the `privilegedescalation-dev` namespace. Nothing
# persists beyond the test run — teardown cleans up all created resources. # persists beyond the test run — teardown cleans up all created resources.
# #
# Prerequisites: # Prerequisites:
# - Plugin built (dist/ exists with plugin-main.js + package.json) # - Plugin built (dist/ exists with plugin-main.js + package.json)
# - kubectl configured with cluster access # - kubectl configured with cluster access
# RBAC is managed via Flux from privilegedescalation/infra/base/rbac/e2e-ci-runner-headlamp-rbac.yaml. # - RBAC applied: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
# The infra repo is the source of truth — do not apply this file directly.
# Apply RBAC first: kubectl apply -f privilegedescalation/infra/base/rbac/e2e-ci-runner-headlamp-rbac.yaml
# #
# Environment: # Environment:
# E2E_NAMESPACE — namespace for E2E Headlamp (default: headlamp-plugins-e2e) # E2E_NAMESPACE — namespace for E2E Headlamp (default: privilegedescalation-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e) # E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
# HEADLAMP_VERSION — Headlamp image tag (default: latest) # HEADLAMP_VERSION — Headlamp image tag (default: latest)
set -euo pipefail set -euo pipefail
@@ -24,7 +22,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST_DIR="$REPO_ROOT/dist" DIST_DIR="$REPO_ROOT/dist"
E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-plugins-e2e}" E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}" HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}"
@@ -37,7 +35,7 @@ fi
echo "Checking RBAC permissions in namespace '${E2E_NAMESPACE}'..." echo "Checking RBAC permissions in namespace '${E2E_NAMESPACE}'..."
if ! kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" --quiet 2>/dev/null; then if ! kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" --quiet 2>/dev/null; then
echo "ERROR: Missing RBAC — cannot delete configmaps in namespace '${E2E_NAMESPACE}'." >&2 echo "ERROR: Missing RBAC — cannot delete configmaps in namespace '${E2E_NAMESPACE}'." >&2
echo " Apply RBAC first: kubectl apply -f privilegedescalation/infra/base/rbac/e2e-ci-runner-headlamp-rbac.yaml" >&2 echo " Apply RBAC first: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml" >&2
exit 1 exit 1
fi fi
+2 -5
View File
@@ -3,17 +3,14 @@
# #
# Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh. # Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh.
# #
# RBAC is managed via Flux from privilegedescalation/infra/base/rbac/e2e-ci-runner-headlamp-rbac.yaml.
# The infra repo is the source of truth — do not apply this file directly.
#
# Environment: # Environment:
# E2E_NAMESPACE — namespace to clean up (default: headlamp-plugins-e2e) # E2E_NAMESPACE — namespace to clean up (default: privilegedescalation-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e) # E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-plugins-e2e}" E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
echo "=== E2E Headlamp Teardown ===" echo "=== E2E Headlamp Teardown ==="
-23
View File
@@ -151,27 +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();
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();
});
}); });
+3 -18
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,7 +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(ApiProxy.request(url), DEFAULT_REQUEST_TIMEOUT_MS); const list = await ApiProxy.request(url);
if (!cancelled && isKubeList(list)) { if (!cancelled && isKubeList(list)) {
const gpuPluginPods = filterIntelGpuPluginPods(list.items); const gpuPluginPods = filterIntelGpuPluginPods(list.items);
foundPluginPods.push(...gpuPluginPods); foundPluginPods.push(...gpuPluginPods);