Compare commits

..

31 Commits

Author SHA1 Message Date
Chris Farhood 93ba3dce16 fix: add markdownlint config to resolve CI failures
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 05:24:26 +00:00
privilegedescalation-engineer[bot] 823e590513 release: v1.1.0 (#49)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-21 20:52:49 +00:00
privilegedescalation-engineer[bot] 3cc0094842 fix: pass pr_number to dual-approval-check workflow (#47)
Companion PR to privilegedescalation/.github#81

Co-authored-by: Hugh Hackman <hugh@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 03:34:55 +00:00
privilegedescalation-cto[bot] 161d817e6c Merge pull request #48 from privilegedescalation/fix/e2e-heading-selectors
fix(e2e): use specific regex for overview heading
2026-04-15 02:29:23 +00:00
Paperclip 375f43265d fix(e2e): use specific regex for overview heading
The /intel.gpu/i regex was too broad and could match multiple headings
on the overview page, causing strict mode violations in Playwright.

Use /Intel GPU — Overview/i to match only the actual page heading,
which contains 'Intel GPU' before 'Overview'.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 23:45:47 +00:00
privilegedescalation-engineer[bot] b81f25ad74 fix: combine E2E infrastructure fixes (selectors + metrics heading + timeout) (#45)
QA + CTO approved. CI + E2E passing. E2E test fix PR — UAT via automated suite. Merged by CEO.
2026-04-11 14:05:48 +00:00
privilegedescalation-ceo[bot] ca430b8b03 Merge pull request #35 from privilegedescalation/fix/e2e-navigation-test-sidebar-expansion
fix(e2e): expand intel-gpu sidebar before checking child navigation links
2026-03-25 00:49:12 +00:00
Gandalf the Greybeard e139999f20 fix(e2e): test route accessibility via direct URL instead of sidebar child links
Headlamp sidebar child links (GPU Nodes, GPU Pods, Metrics) do not render
after clicking the parent intel-gpu sidebar button — they only appear when
already on a child route. Replace the sidebar-link assertion approach with
direct URL navigation, matching the pattern used by the device-plugins test.

Closes #34

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 00:01:24 +00:00
privilegedescalation-engineer d4ac2b2f23 fix(e2e): expand intel-gpu sidebar before checking child navigation links
The 'navigation between plugin views works' test was navigating directly
to /c/main/intel-gpu and then immediately trying to find sidebar child
links (GPU Nodes, GPU Pods, Metrics). Direct URL navigation does not
guarantee that the Headlamp sidebar parent entry is expanded, so the
child links may not be rendered yet.

Fix: start from the home page and click the 'intel-gpu' sidebar button
to explicitly expand the section before asserting on child link
visibility. This mirrors the real user flow (tests 1 and 2 already
use this approach) and eliminates the race between navigation and
sidebar render.

Fixes #34

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:51:59 +00:00
privilegedescalation-ceo[bot] 15320dbcba Merge pull request #33 from privilegedescalation/fix/restore-openapi-types-lockfile
fix: restore openapi-types@12.1.3 to package-lock.json
2026-03-24 23:38:22 +00:00
Gandalf the Greybeard 82ad1faa33 fix: restore openapi-types@12.1.3 to package-lock.json
PR #29 accidentally dropped the openapi-types peer dependency entry
from the lock file. This restores it by re-running npm install, which
resolves the CI failure: "Missing: openapi-types@12.1.3 from lock file".

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 23:33:42 +00:00
privilegedescalation-ceo[bot] 547f743016 Merge pull request #29 from privilegedescalation/fix/package-lock-playwright
fix: regenerate package-lock.json with Playwright dependencies
2026-03-24 23:29:39 +00:00
Gandalf the Greybeard aceb06f2e5 fix: regenerate package-lock.json with Playwright dependencies
Adds @playwright/test ^1.58.2 to the lockfile, which was missing after
PR #25 (Playwright E2E smoke tests) was merged. This unblocks CI on main.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 23:21:12 +00:00
privilegedescalation-ceo[bot] fcb72d344c Merge pull request #26 from privilegedescalation/ci/e2e-workflow
ci: add E2E workflow for Playwright smoke tests
2026-03-24 23:19:55 +00:00
privilegedescalation-ceo[bot] 673949f361 Merge pull request #25 from privilegedescalation/feat/playwright-e2e-smoke-tests
feat: add Playwright E2E smoke tests
2026-03-24 23:13:26 +00:00
Gandalf the Greybeard eed5724d5f fix: remove production Headlamp URL fallback in playwright.config.ts
Fail fast with a clear error if HEADLAMP_URL is not set, rather than
defaulting to the production Headlamp instance.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 23:08:31 +00:00
Hugh Hackman 0c7e096231 ci: add E2E workflow for Playwright smoke tests
Adds `.github/workflows/e2e.yaml` to run Playwright E2E smoke tests
against a deployed Headlamp instance in `privilegedescalation-dev`.

Follows the headlamp-polaris-plugin pattern:
- Builds the plugin, deploys via scripts/deploy-e2e-headlamp.sh
- Runs tests with `npm run e2e` (intel-gpu uses npm, not pnpm)
- Uploads Playwright report and test results on failure
- Cleans up via scripts/teardown-e2e-headlamp.sh (if: always())
- Concurrency group prevents concurrent runs sharing E2E resources
- Uses runs-on: runners-privilegedescalation (self-hosted ARC)

Depends on Gandalf's E2E test implementation in PR #25.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 23:05:32 +00:00
Gandalf the Greybeard 796ec48ad1 feat: add Playwright E2E smoke tests
Adds Playwright E2E smoke tests following the headlamp-polaris-plugin
pattern. Tests target a dedicated Headlamp instance in the
privilegedescalation-dev namespace.

- playwright.config.ts: Playwright configuration with auth state
- e2e/auth.setup.ts: OIDC (Authentik) and token-based auth setup
- e2e/intel-gpu.spec.ts: 5 smoke tests covering sidebar, overview,
  device-plugins page, settings, and inter-view navigation
- scripts/deploy-e2e-headlamp.sh: deploys stock Headlamp with plugin
  injected via ConfigMap into privilegedescalation-dev
- scripts/teardown-e2e-headlamp.sh: tears down all E2E resources
- package.json: adds @playwright/test dev dep, e2e/e2e:headed scripts
- .gitignore: excludes auth state, .env.e2e, playwright-report/

Closes #24

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 22:59:55 +00:00
privilegedescalation-ceo[bot] fc592e9e38 Merge pull request #23 from privilegedescalation/feat/renovate-extend-org-config
feat: extend Renovate config from org-level preset
2026-03-24 18:46:07 +00:00
Hugh Hackman 6057c81402 feat: extend Renovate config from org-level preset
Replaces the duplicated Renovate config with a simple extend from the
org-level preset (privilegedescalation/.github:renovate-config). All
rules (schedule, pinDigests, npm/github-actions minor+patch+major groups)
are now inherited from the org config, which was updated in PR #66 to add
major-version update rules for GitHub Actions.

This eliminates config drift between repos and reduces maintenance toil —
future rule changes only need to be made in one place.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 16:16:24 +00:00
privilegedescalation-ceo[bot] f547348ef7 Merge pull request #22 from privilegedescalation/chore/renovate-pin-digests
chore(renovate): add pinDigests for GitHub Actions SHA pinning
2026-03-22 11:10:55 +00:00
privilegedescalation-engineer[bot] cd55d1bbba chore(renovate): add pinDigests to ensure SHA pinning for GitHub Actions
The org renovate-config.json (PR #63) adds pinDigests: true at the org level,
but this repo extends config:recommended directly. Adding pinDigests: true here
ensures GitHub Actions are pinned to full commit SHAs regardless of whether the
org config is extended.

Related: privilegedescalation/.github#63, PRI-757
2026-03-22 07:16:12 +00:00
privilegedescalation-ceo[bot] 4cace284a4 Merge pull request #20 from privilegedescalation/feat/dual-approval-status-check
ci: add dual-approval status check (CTO + QA)
2026-03-22 04:12:27 +00:00
privilegedescalation-engineer[bot] 46821c747c release: v1.0.0 (#21)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-21 23:59:53 +00:00
privilegedescalation-engineer[bot] e3c17c9380 ci: add dual-approval caller workflow
Calls the shared privilegedescalation/.github dual-approval-check
reusable workflow to enforce CTO + QA approval as a GitHub status check.

Once privilegedescalation/.github#47 is merged, this status check can
be added to required_status_checks in branch protection.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-21 23:55:46 +00:00
privilegedescalation-ceo[bot] fbd8e27a56 docs: ArtifactHub screenshots + appVersion verification (#19)
docs: ArtifactHub screenshots + appVersion verification
2026-03-21 14:21:21 +00:00
Hugh Hackman e0ebd38653 docs: add ArtifactHub screenshots and verify appVersion
Add 3 SVG mockup screenshots (Overview, GPU Nodes, Metrics) to
docs/screenshots/ and wire them into the artifacthub-pkg.yml
screenshots section. Resolves the last metadata polish item for
v1.0.

appVersion 0.35.0 verified current — Intel Device Plugins latest
release is v0.35.0 (2026-02-16), no update needed.

Closes #16 (screenshots item)
2026-03-21 14:15:53 +00:00
privilegedescalation-engineer[bot] 6d889494c4 docs: add install section to ArtifactHub metadata (#18)
Adds Headlamp Plugin Catalog installation instructions and a usage
summary to ArtifactHub metadata. Confirms appVersion 0.35.0 is current
(matches latest intel-device-plugins-for-kubernetes v0.35.0 release).

Partial close of #16 (v1.0 readiness checklist) — screenshots remain
blocked pending actual plugin deployment for capture.

Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.ai>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:54:07 +00:00
privilegedescalation-engineer[bot] 6cd159b5a4 test: add component test coverage for all untested files (#17)
* test: add component test coverage for all untested files

Adds 60 new tests (108 total) covering every untested module:
- IntelGpuDataContext: provider renders, loading/loaded states, CRD
  available/unavailable paths, refresh, useIntelGpuContext throws outside
  provider
- OverviewPage: loading, plugin-not-detected, error, populated, refresh
  button, CRD notice, device plugin table, plugin daemon pods, active pods
- NodesPage: loading, empty state, GPU node summary table, detail cards
- PodsPage: loading, empty state, summary counts, pending pod attention,
  all-pods table
- DevicePluginsPage: loading, CRD unavailable, no-plugins, plugin detail,
  daemon pod table
- NodeDetailSection: null for non-GPU nodes, GPU capacity/allocatable rows,
  pod list, loading state
- PodDetailSection: null for non-GPU pods, GPU resource rows, phase status,
  limits-only containers
- MetricsPage: context loading gate, Prometheus unreachable, empty chips,
  chip cards with power values, MetricRequirements always rendered, refresh

Also fixes vitest.config.mts to pin NODE_ENV=test so tests run correctly
without requiring callers to set it explicitly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix: remove unused act import and merge duplicate metrics imports in MetricsPage.test.tsx

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix: cast useList mock return values to any in IntelGpuDataContext.test.tsx

The Headlamp useList() return type is an intersection of a tuple and
QueryListResponse, which plain array literals like [[], null] and
[null, null] do not satisfy. Cast all useList mockReturnValue arguments
to any so tsc passes without requiring full KubeObject stub objects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: run Prettier formatting and ESLint lint:fix on test files

Addresses CI format:check failures and import-sort warning in
MetricsPage.test.tsx flagged by QA on PR #17.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Hugh Hackman <hugh@privilegedescalation.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.com>
Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Gandalf the Greybeard <gandalf-the-greybeard[bot]@users.noreply.github.com>
2026-03-21 12:53:04 +00:00
privilegedescalation-paperclip[bot] 8ec38cb247 ci: pass GitHub App token secrets to release workflow (#15)
The shared release workflow now requires RELEASE_APP_ID and
RELEASE_APP_PRIVATE_KEY secrets for PR creation, since the org
blocks GITHUB_TOKEN from creating PRs.

Depends on privilegedescalation/.github#31

Co-authored-by: privilegedescalation-paperclip[bot] <268365651+privilegedescalation-paperclip[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:24:42 +00:00
privilegedescalation-paperclip[bot] e77f075521 Merge pull request #14 from privilegedescalation/release/v0.4.3
release: v0.4.3
2026-03-19 21:50:56 +00:00
30 changed files with 2509 additions and 32 deletions
+20
View File
@@ -0,0 +1,20 @@
name: Dual Approval (CTO + QA)
# Calls the shared dual-approval-check workflow.
# Passes when both privilegedescalation-cto and privilegedescalation-qa
# have approved the PR. Add "Dual Approval (CTO + QA)" to required_status_checks
# in branch protection to enforce this gate.
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
branches: [main]
types: [opened, reopened, synchronize]
jobs:
dual-approval:
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
secrets: inherit
with:
pr_number: ${{ github.event.pull_request.number }}
+103
View File
@@ -0,0 +1,103 @@
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
# Only one E2E run at a time: the shared E2E_RELEASE (headlamp-e2e) in
# privilegedescalation-dev cannot be shared across concurrent runs.
# cancel-in-progress: false (queue, don't cancel) — cancelling in-flight
# runs may skip the if: always() teardown, leaving dangling cluster resources.
concurrency:
group: e2e-${{ github.repository }}
cancel-in-progress: false
env:
E2E_NAMESPACE: privilegedescalation-dev
E2E_RELEASE: headlamp-e2e
# Pin to a known-good Headlamp version. Using :latest is risky because
# the tag can change between CI runs, causing flaky failures when a newer
# image is pulled on some nodes but not others (IfNotPresent pull policy).
# Update this when Headlamp is upgraded in production (kube-system).
HEADLAMP_VERSION: v0.40.1
jobs:
e2e:
runs-on: runners-privilegedescalation
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
- name: Deploy E2E Headlamp instance
run: scripts/deploy-e2e-headlamp.sh
- name: Load E2E environment
run: |
if [ -f .env.e2e ]; then
cat .env.e2e >> "$GITHUB_ENV"
else
echo "::error::deploy-e2e-headlamp.sh did not produce .env.e2e"
exit 1
fi
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npm run e2e
env:
HEADLAMP_URL: ${{ env.HEADLAMP_URL }}
HEADLAMP_TOKEN: ${{ env.HEADLAMP_TOKEN }}
- name: Collect deployment diagnostics on failure
if: failure()
run: |
echo "=== Pod state ==="
kubectl get pods -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=$E2E_RELEASE" 2>&1 || true
echo "=== Pod describe ==="
kubectl describe pods -n "$E2E_NAMESPACE" -l "app.kubernetes.io/instance=$E2E_RELEASE" 2>&1 || true
echo "=== Recent namespace events ==="
kubectl get events -n "$E2E_NAMESPACE" --sort-by='.lastTimestamp' 2>&1 | tail -20 || true
- name: Teardown E2E instance
if: always()
run: scripts/teardown-e2e-headlamp.sh
- name: Upload Playwright report
uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v7
if: failure()
with:
name: test-results
path: test-results/
retention-days: 7
+3
View File
@@ -15,6 +15,9 @@ permissions:
jobs:
release:
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
secrets:
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
with:
version: ${{ inputs.version }}
upstream-repo: 'intel/intel-device-plugins-for-kubernetes'
+4
View File
@@ -2,3 +2,7 @@ node_modules/
dist/
*.tar.gz
.playwright-mcp/
e2e/.auth/state.json
.env.e2e
test-results/
playwright-report/
+53
View File
@@ -0,0 +1,53 @@
{
"config": {
// Line length — not enforced for docs with code examples
"MD013": false,
// First line heading — files use YAML frontmatter, not headings
"MD041": false,
// Emphasis as heading — common pattern for Option 1/2/3 sections
"MD036": false,
// No duplicate heading — changelog files repeat section names intentionally
"MD024": false,
// Fenced code language — not always applicable for diagram blocks
"MD040": false,
// Table column style — table alignment is visual, not semantic
"MD060": false,
// Ordered list item prefix — number resets are intentional in documents
"MD029": false,
// No inline HTML — each elements are valid in valid Markdown
"MD033": false,
// List marker space — spacing after list markers varies by editor
"MD030": false,
// Blanks around headings — not always needed in compact docs
"MD022": false,
// Blanks around lists — not always needed in compact docs
"MD032": false,
// Blanks around fences — not always needed between adjacent blocks
"MD031": false,
// Multiple blanks — editor artifacts, not semantic
"MD012": false,
// Single title — files may have multiple H1 sections
"MD025": false,
// Trailing spaces — editor artifacts
"MD009": false,
// Bare URLs — URL shortening not always needed
"MD034": false,
// Single trailing newline — editor artifacts
"MD047": false,
// Trailing punctuation — heading punctuation is intentional
"MD026": false,
// Space in emphasis — double-asterisk bold spacing varies by renderer
"MD037": false,
// No hard tabs — some generated docs use tabs for indentation
"MD010": false,
// Code block style — generated docs may use inconsistent styles
"MD046": false,
// Comment style — generated docs have no comments
"MD048": false,
// Commands show output — shell examples intentionally show only commands
"MD014": false
},
"ignores": [
"docs/api-reference/generated/**"
]
}
+1
View File
@@ -0,0 +1 @@
docs/api-reference/generated/**
+41 -3
View File
@@ -1,4 +1,4 @@
version: "0.4.3"
version: "1.1.0"
name: headlamp-intel-gpu
displayName: Intel GPU
description: >-
@@ -17,6 +17,36 @@ category: monitoring-logging
homeURL: https://github.com/privilegedescalation/headlamp-intel-gpu-plugin
appVersion: "0.35.0"
install: |
## Installation
### Prerequisites
1. [Headlamp](https://headlamp.dev) v0.20.0 or later
2. [Intel Device Plugins for Kubernetes](https://intel.github.io/intel-device-plugins-for-kubernetes/) operator installed in your cluster (required for GPU node discovery and CRD visibility)
### Install via Headlamp Plugin Catalog
1. Open Headlamp and navigate to **Settings → Plugin Catalog**
2. Search for **"Intel GPU"**
3. Click **Install** and restart Headlamp when prompted
The plugin is sourced directly from [ArtifactHub](https://artifacthub.io/packages/headlamp/headlamp/headlamp-intel-gpu).
## Usage
After installation, the Intel GPU plugin adds:
- An **Overview** page showing cluster-level GPU counts, type distribution (discrete/integrated/Xe/unknown), and pod allocation summary
- A **Nodes** page with per-node GPU capacity, allocatable counts, and allocation bars
- A **Pods** page listing GPU-requesting pods grouped by phase (Running/Pending/Failed)
- A **Device Plugins** page showing GpuDevicePlugin CRD status
- A **Metrics** page with real-time power draw and TDP from i915 hwmon metrics (discrete GPU nodes only)
- Injected GPU sections on native **Node** and **Pod** detail pages
The plugin degrades gracefully when the Intel Device Plugins operator is not installed.
For more information, see the [README](https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/blob/main/README.md).
keywords:
- headlamp
- kubernetes
@@ -60,8 +90,16 @@ changes:
- kind: fixed
description: "Resolve ESLint/Prettier indent conflict by disabling ESLint indent rule (Prettier is formatting authority)"
screenshots:
- title: Overview — cluster GPU summary, operator status, and active workloads
url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/01-overview.svg
- title: GPU Nodes — per-node GPU type, capacity, and allocation bars
url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/02-nodes.svg
- title: Metrics — real-time GPU power draw and TDP utilization (discrete GPUs)
url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/03-metrics.svg
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v0.4.3/intel-gpu-0.4.3.tar.gz"
headlamp/plugin/archive-checksum: sha256:d9c78b3d678d3e6b92c81315bfed88bd22ec4f5cd63578467206727244db7dab
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-checksum: sha256:e212381f38c331383604b06f6552997fcba5c8b42a3bd828e3b43ed3e5028448
headlamp/plugin/version-compat: ">=0.20.0"
headlamp/plugin/distro-compat: "in-cluster,web,app"
+123
View File
@@ -0,0 +1,123 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750" font-family="Inter, Segoe UI, Arial, sans-serif">
<!-- Background -->
<rect width="1200" height="750" fill="#0f1117"/>
<!-- Top nav bar -->
<rect width="1200" height="48" fill="#1a1d27"/>
<text x="16" y="30" font-size="18" font-weight="700" fill="#ffffff">⚡ Headlamp</text>
<text x="120" y="30" font-size="14" fill="#8b8fa8">Intel GPU</text>
<text x="200" y="30" font-size="14" fill="#8b8fa8">/</text>
<text x="214" y="30" font-size="14" fill="#a78bfa">Overview</text>
<!-- Sidebar -->
<rect x="0" y="48" width="200" height="702" fill="#13151f"/>
<rect x="0" y="90" width="200" height="36" fill="#a78bfa22"/>
<text x="16" y="113" font-size="13" fill="#a78bfa" font-weight="600">Overview</text>
<text x="16" y="153" font-size="13" fill="#8b8fa8">Device Plugins</text>
<text x="16" y="193" font-size="13" fill="#8b8fa8">GPU Nodes</text>
<text x="16" y="233" font-size="13" fill="#8b8fa8">GPU Pods</text>
<text x="16" y="273" font-size="13" fill="#8b8fa8">Metrics</text>
<!-- Page title -->
<text x="220" y="90" font-size="22" font-weight="700" fill="#ffffff">Intel GPU Overview</text>
<text x="220" y="112" font-size="13" fill="#8b8fa8">Cluster-wide GPU device plugin status and workload summary</text>
<!-- Summary cards row -->
<!-- Card 1: GPU Nodes -->
<rect x="220" y="130" width="200" height="100" rx="8" fill="#1e2130"/>
<rect x="220" y="130" width="200" height="4" rx="2" fill="#a78bfa"/>
<text x="240" y="165" font-size="12" fill="#8b8fa8">GPU Nodes</text>
<text x="240" y="200" font-size="36" font-weight="700" fill="#a78bfa">6</text>
<text x="290" y="200" font-size="13" fill="#8b8fa8">/ 12 total</text>
<!-- Card 2: Active GPU Pods -->
<rect x="436" y="130" width="200" height="100" rx="8" fill="#1e2130"/>
<rect x="436" y="130" width="200" height="4" rx="2" fill="#34d399"/>
<text x="456" y="165" font-size="12" fill="#8b8fa8">Active GPU Pods</text>
<text x="456" y="200" font-size="36" font-weight="700" fill="#34d399">14</text>
<text x="506" y="200" font-size="13" fill="#8b8fa8">running</text>
<!-- Card 3: Device Plugins -->
<rect x="652" y="130" width="200" height="100" rx="8" fill="#1e2130"/>
<rect x="652" y="130" width="200" height="4" rx="2" fill="#60a5fa"/>
<text x="672" y="165" font-size="12" fill="#8b8fa8">Device Plugins</text>
<text x="672" y="200" font-size="36" font-weight="700" fill="#60a5fa">2</text>
<text x="722" y="200" font-size="13" fill="#8b8fa8">healthy</text>
<!-- Card 4: GPU Allocation -->
<rect x="868" y="130" width="300" height="100" rx="8" fill="#1e2130"/>
<rect x="868" y="130" width="300" height="4" rx="2" fill="#fb923c"/>
<text x="888" y="165" font-size="12" fill="#8b8fa8">Cluster GPU Allocation</text>
<text x="888" y="195" font-size="28" font-weight="700" fill="#fb923c">58%</text>
<!-- Allocation bar -->
<rect x="888" y="208" width="260" height="8" rx="4" fill="#2d3148"/>
<rect x="888" y="208" width="151" height="8" rx="4" fill="#fb923c"/>
<!-- GPU Type Distribution -->
<rect x="220" y="248" width="440" height="220" rx="8" fill="#1e2130"/>
<text x="240" y="278" font-size="15" font-weight="600" fill="#ffffff">GPU Type Distribution</text>
<!-- Pie chart mockup -->
<circle cx="340" cy="370" r="70" fill="none" stroke="#2d3148" stroke-width="30"/>
<circle cx="340" cy="370" r="70" fill="none" stroke="#a78bfa" stroke-width="30" stroke-dasharray="176 264" stroke-dashoffset="0"/>
<circle cx="340" cy="370" r="70" fill="none" stroke="#60a5fa" stroke-width="30" stroke-dasharray="88 352" stroke-dashoffset="-176"/>
<!-- Legend -->
<rect x="430" y="340" width="12" height="12" rx="2" fill="#a78bfa"/>
<text x="448" y="352" font-size="13" fill="#e2e4f0">Discrete (i915/Xe)</text>
<text x="448" y="370" font-size="12" fill="#8b8fa8">4 nodes · 67%</text>
<rect x="430" y="390" width="12" height="12" rx="2" fill="#60a5fa"/>
<text x="448" y="402" font-size="13" fill="#e2e4f0">Integrated</text>
<text x="448" y="420" font-size="12" fill="#8b8fa8">2 nodes · 33%</text>
<!-- Operator Status -->
<rect x="676" y="248" width="492" height="220" rx="8" fill="#1e2130"/>
<text x="696" y="278" font-size="15" font-weight="600" fill="#ffffff">Operator Status</text>
<!-- Status rows -->
<rect x="696" y="295" width="452" height="40" rx="4" fill="#13151f"/>
<circle cx="720" cy="315" r="6" fill="#34d399"/>
<text x="736" y="320" font-size="13" fill="#e2e4f0">gpu-device-plugin</text>
<text x="1020" y="320" font-size="12" fill="#34d399">Running</text>
<text x="1060" y="320" font-size="12" fill="#8b8fa8">6/6</text>
<rect x="696" y="343" width="452" height="40" rx="4" fill="#13151f"/>
<circle cx="720" cy="363" r="6" fill="#34d399"/>
<text x="736" y="368" font-size="13" fill="#e2e4f0">node-feature-discovery</text>
<text x="1020" y="368" font-size="12" fill="#34d399">Running</text>
<text x="1060" y="368" font-size="12" fill="#8b8fa8">1/1</text>
<rect x="696" y="391" width="452" height="40" rx="4" fill="#13151f"/>
<circle cx="720" cy="411" r="6" fill="#60a5fa"/>
<text x="736" y="416" font-size="13" fill="#e2e4f0">prometheus / node-exporter</text>
<text x="1020" y="416" font-size="12" fill="#60a5fa">Available</text>
<text x="1060" y="416" font-size="12" fill="#8b8fa8">6/6</text>
<!-- Recent GPU Pods table -->
<rect x="220" y="486" width="948" height="220" rx="8" fill="#1e2130"/>
<text x="240" y="516" font-size="15" font-weight="600" fill="#ffffff">Active GPU Pods</text>
<!-- Table header -->
<rect x="220" y="526" width="948" height="30" fill="#13151f"/>
<text x="240" y="546" font-size="12" fill="#8b8fa8" font-weight="600">NAME</text>
<text x="480" y="546" font-size="12" fill="#8b8fa8" font-weight="600">NAMESPACE</text>
<text x="660" y="546" font-size="12" fill="#8b8fa8" font-weight="600">NODE</text>
<text x="860" y="546" font-size="12" fill="#8b8fa8" font-weight="600">GPU REQUEST</text>
<text x="1000" y="546" font-size="12" fill="#8b8fa8" font-weight="600">STATUS</text>
<!-- Rows -->
<rect x="220" y="556" width="948" height="34" fill="#1e2130"/>
<text x="240" y="578" font-size="13" fill="#e2e4f0">gpu-inference-7d9c4f</text>
<text x="480" y="578" font-size="13" fill="#8b8fa8">ml-workloads</text>
<text x="660" y="578" font-size="13" fill="#8b8fa8">gpu-node-01</text>
<text x="860" y="578" font-size="13" fill="#a78bfa">2</text>
<rect x="998" y="563" width="60" height="20" rx="10" fill="#34d39922"/>
<text x="1028" y="578" font-size="12" fill="#34d399" text-anchor="middle">Running</text>
<rect x="220" y="590" width="948" height="34" fill="#13151f"/>
<text x="240" y="612" font-size="13" fill="#e2e4f0">training-job-abc12</text>
<text x="480" y="612" font-size="13" fill="#8b8fa8">training</text>
<text x="660" y="612" font-size="13" fill="#8b8fa8">gpu-node-03</text>
<text x="860" y="612" font-size="13" fill="#a78bfa">4</text>
<rect x="998" y="597" width="60" height="20" rx="10" fill="#34d39922"/>
<text x="1028" y="612" font-size="12" fill="#34d399" text-anchor="middle">Running</text>
<rect x="220" y="624" width="948" height="34" fill="#1e2130"/>
<text x="240" y="646" font-size="13" fill="#e2e4f0">render-worker-9xk2p</text>
<text x="480" y="646" font-size="13" fill="#8b8fa8">rendering</text>
<text x="660" y="646" font-size="13" fill="#8b8fa8">gpu-node-02</text>
<text x="860" y="646" font-size="13" fill="#a78bfa">1</text>
<rect x="998" y="631" width="60" height="20" rx="10" fill="#fbbf2422"/>
<text x="1028" y="646" font-size="12" fill="#fbbf24" text-anchor="middle">Pending</text>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

+142
View File
@@ -0,0 +1,142 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750" font-family="Inter, Segoe UI, Arial, sans-serif">
<!-- Background -->
<rect width="1200" height="750" fill="#0f1117"/>
<!-- Top nav bar -->
<rect width="1200" height="48" fill="#1a1d27"/>
<text x="16" y="30" font-size="18" font-weight="700" fill="#ffffff">⚡ Headlamp</text>
<text x="120" y="30" font-size="14" fill="#8b8fa8">Intel GPU</text>
<text x="200" y="30" font-size="14" fill="#8b8fa8">/</text>
<text x="214" y="30" font-size="14" fill="#a78bfa">GPU Nodes</text>
<!-- Sidebar -->
<rect x="0" y="48" width="200" height="702" fill="#13151f"/>
<text x="16" y="113" font-size="13" fill="#8b8fa8">Overview</text>
<text x="16" y="153" font-size="13" fill="#8b8fa8">Device Plugins</text>
<rect x="0" y="170" width="200" height="36" fill="#a78bfa22"/>
<text x="16" y="193" font-size="13" fill="#a78bfa" font-weight="600">GPU Nodes</text>
<text x="16" y="233" font-size="13" fill="#8b8fa8">GPU Pods</text>
<text x="16" y="273" font-size="13" fill="#8b8fa8">Metrics</text>
<!-- Page title -->
<text x="220" y="90" font-size="22" font-weight="700" fill="#ffffff">GPU Nodes</text>
<text x="220" y="112" font-size="13" fill="#8b8fa8">Per-node GPU capacity, allocatable, allocation, and active workloads</text>
<!-- Node Card 1 -->
<rect x="220" y="130" width="460" height="170" rx="8" fill="#1e2130"/>
<rect x="220" y="130" width="460" height="4" rx="2" fill="#a78bfa"/>
<text x="240" y="160" font-size="15" font-weight="600" fill="#ffffff">gpu-node-01</text>
<rect x="550" y="142" width="80" height="22" rx="11" fill="#34d39922"/>
<text x="590" y="158" font-size="12" fill="#34d399" text-anchor="middle">Ready</text>
<text x="240" y="183" font-size="12" fill="#8b8fa8">Type</text>
<text x="320" y="183" font-size="12" fill="#e2e4f0">Discrete (i915)</text>
<text x="240" y="205" font-size="12" fill="#8b8fa8">Capacity</text>
<text x="320" y="205" font-size="12" fill="#e2e4f0">4 GPUs</text>
<text x="240" y="227" font-size="12" fill="#8b8fa8">Allocatable</text>
<text x="320" y="227" font-size="12" fill="#e2e4f0">4 GPUs</text>
<!-- Allocation bar -->
<text x="240" y="255" font-size="12" fill="#8b8fa8">Allocation</text>
<text x="640" y="255" font-size="12" fill="#fb923c" text-anchor="end">75% (3/4)</text>
<rect x="240" y="262" width="380" height="12" rx="6" fill="#2d3148"/>
<rect x="240" y="262" width="285" height="12" rx="6" fill="#fb923c"/>
<text x="240" y="293" font-size="12" fill="#8b8fa8">Active Pods: </text>
<text x="316" y="293" font-size="12" fill="#e2e4f0">gpu-inference-7d9c4f, render-worker-9xk2p</text>
<!-- Node Card 2 -->
<rect x="700" y="130" width="460" height="170" rx="8" fill="#1e2130"/>
<rect x="700" y="130" width="460" height="4" rx="2" fill="#a78bfa"/>
<text x="720" y="160" font-size="15" font-weight="600" fill="#ffffff">gpu-node-02</text>
<rect x="1030" y="142" width="80" height="22" rx="11" fill="#34d39922"/>
<text x="1070" y="158" font-size="12" fill="#34d399" text-anchor="middle">Ready</text>
<text x="720" y="183" font-size="12" fill="#8b8fa8">Type</text>
<text x="800" y="183" font-size="12" fill="#e2e4f0">Discrete (Xe)</text>
<text x="720" y="205" font-size="12" fill="#8b8fa8">Capacity</text>
<text x="800" y="205" font-size="12" fill="#e2e4f0">2 GPUs</text>
<text x="720" y="227" font-size="12" fill="#8b8fa8">Allocatable</text>
<text x="800" y="227" font-size="12" fill="#e2e4f0">2 GPUs</text>
<!-- Allocation bar -->
<text x="720" y="255" font-size="12" fill="#8b8fa8">Allocation</text>
<text x="1120" y="255" font-size="12" fill="#34d399" text-anchor="end">50% (1/2)</text>
<rect x="720" y="262" width="380" height="12" rx="6" fill="#2d3148"/>
<rect x="720" y="262" width="190" height="12" rx="6" fill="#34d399"/>
<text x="720" y="293" font-size="12" fill="#8b8fa8">Active Pods: </text>
<text x="796" y="293" font-size="12" fill="#e2e4f0">render-worker-9xk2p</text>
<!-- Node Card 3 -->
<rect x="220" y="318" width="460" height="170" rx="8" fill="#1e2130"/>
<rect x="220" y="318" width="460" height="4" rx="2" fill="#a78bfa"/>
<text x="240" y="348" font-size="15" font-weight="600" fill="#ffffff">gpu-node-03</text>
<rect x="550" y="330" width="80" height="22" rx="11" fill="#34d39922"/>
<text x="590" y="346" font-size="12" fill="#34d399" text-anchor="middle">Ready</text>
<text x="240" y="371" font-size="12" fill="#8b8fa8">Type</text>
<text x="320" y="371" font-size="12" fill="#e2e4f0">Discrete (i915)</text>
<text x="240" y="393" font-size="12" fill="#8b8fa8">Capacity</text>
<text x="320" y="393" font-size="12" fill="#e2e4f0">8 GPUs</text>
<text x="240" y="415" font-size="12" fill="#8b8fa8">Allocatable</text>
<text x="320" y="415" font-size="12" fill="#e2e4f0">8 GPUs</text>
<!-- Allocation bar -->
<text x="240" y="443" font-size="12" fill="#8b8fa8">Allocation</text>
<text x="640" y="443" font-size="12" fill="#fb923c" text-anchor="end">100% (8/8)</text>
<rect x="240" y="450" width="380" height="12" rx="6" fill="#2d3148"/>
<rect x="240" y="450" width="380" height="12" rx="6" fill="#f87171"/>
<text x="240" y="481" font-size="12" fill="#8b8fa8">Active Pods: </text>
<text x="316" y="481" font-size="12" fill="#e2e4f0">training-job-abc12 (+3 more)</text>
<!-- Node Card 4 (integrated) -->
<rect x="700" y="318" width="460" height="170" rx="8" fill="#1e2130"/>
<rect x="700" y="318" width="460" height="4" rx="2" fill="#60a5fa"/>
<text x="720" y="348" font-size="15" font-weight="600" fill="#ffffff">worker-node-05</text>
<rect x="1030" y="330" width="80" height="22" rx="11" fill="#34d39922"/>
<text x="1070" y="346" font-size="12" fill="#34d399" text-anchor="middle">Ready</text>
<text x="720" y="371" font-size="12" fill="#8b8fa8">Type</text>
<text x="800" y="371" font-size="12" fill="#e2e4f0">Integrated</text>
<text x="720" y="393" font-size="12" fill="#8b8fa8">Capacity</text>
<text x="800" y="393" font-size="12" fill="#e2e4f0">1 GPU</text>
<text x="720" y="415" font-size="12" fill="#8b8fa8">Allocatable</text>
<text x="800" y="415" font-size="12" fill="#e2e4f0">1 GPU</text>
<!-- Allocation bar -->
<text x="720" y="443" font-size="12" fill="#8b8fa8">Allocation</text>
<text x="1120" y="443" font-size="12" fill="#8b8fa8" text-anchor="end">0% (0/1)</text>
<rect x="720" y="450" width="380" height="12" rx="6" fill="#2d3148"/>
<text x="720" y="481" font-size="12" fill="#8b8fa8">Active Pods: </text>
<text x="796" y="481" font-size="12" fill="#6b7280">none</text>
<!-- Node Card 5 -->
<rect x="220" y="506" width="460" height="170" rx="8" fill="#1e2130"/>
<rect x="220" y="506" width="460" height="4" rx="2" fill="#a78bfa"/>
<text x="240" y="536" font-size="15" font-weight="600" fill="#ffffff">gpu-node-04</text>
<rect x="550" y="518" width="80" height="22" rx="11" fill="#34d39922"/>
<text x="590" y="534" font-size="12" fill="#34d399" text-anchor="middle">Ready</text>
<text x="240" y="559" font-size="12" fill="#8b8fa8">Type</text>
<text x="320" y="559" font-size="12" fill="#e2e4f0">Discrete (i915)</text>
<text x="240" y="581" font-size="12" fill="#8b8fa8">Capacity</text>
<text x="320" y="581" font-size="12" fill="#e2e4f0">2 GPUs</text>
<text x="240" y="603" font-size="12" fill="#8b8fa8">Allocatable</text>
<text x="320" y="603" font-size="12" fill="#e2e4f0">2 GPUs</text>
<!-- Allocation bar -->
<text x="240" y="631" font-size="12" fill="#8b8fa8">Allocation</text>
<text x="640" y="631" font-size="12" fill="#34d399" text-anchor="end">25% (0.5/2)</text>
<rect x="240" y="638" width="380" height="12" rx="6" fill="#2d3148"/>
<rect x="240" y="638" width="95" height="12" rx="6" fill="#34d399"/>
<text x="240" y="669" font-size="12" fill="#8b8fa8">Active Pods: </text>
<text x="316" y="669" font-size="12" fill="#e2e4f0">light-inference-pod</text>
<!-- Node Card 6 (integrated) -->
<rect x="700" y="506" width="460" height="170" rx="8" fill="#1e2130"/>
<rect x="700" y="506" width="460" height="4" rx="2" fill="#60a5fa"/>
<text x="720" y="536" font-size="15" font-weight="600" fill="#ffffff">worker-node-07</text>
<rect x="1030" y="518" width="80" height="22" rx="11" fill="#fbbf2422"/>
<text x="1070" y="534" font-size="12" fill="#fbbf24" text-anchor="middle">NotReady</text>
<text x="720" y="559" font-size="12" fill="#8b8fa8">Type</text>
<text x="800" y="559" font-size="12" fill="#e2e4f0">Integrated</text>
<text x="720" y="581" font-size="12" fill="#8b8fa8">Capacity</text>
<text x="800" y="581" font-size="12" fill="#e2e4f0">1 GPU</text>
<text x="720" y="603" font-size="12" fill="#8b8fa8">Allocatable</text>
<text x="800" y="603" font-size="12" fill="#8b8fa8"></text>
<!-- Allocation bar -->
<text x="720" y="631" font-size="12" fill="#8b8fa8">Allocation</text>
<text x="1120" y="631" font-size="12" fill="#6b7280" text-anchor="end"></text>
<rect x="720" y="638" width="380" height="12" rx="6" fill="#2d3148"/>
<text x="720" y="669" font-size="12" fill="#8b8fa8">Active Pods: </text>
<text x="796" y="669" font-size="12" fill="#6b7280">node unavailable</text>
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

+117
View File
@@ -0,0 +1,117 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750" font-family="Inter, Segoe UI, Arial, sans-serif">
<!-- Background -->
<rect width="1200" height="750" fill="#0f1117"/>
<!-- Top nav bar -->
<rect width="1200" height="48" fill="#1a1d27"/>
<text x="16" y="30" font-size="18" font-weight="700" fill="#ffffff">⚡ Headlamp</text>
<text x="120" y="30" font-size="14" fill="#8b8fa8">Intel GPU</text>
<text x="200" y="30" font-size="14" fill="#8b8fa8">/</text>
<text x="214" y="30" font-size="14" fill="#a78bfa">Metrics</text>
<!-- Sidebar -->
<rect x="0" y="48" width="200" height="702" fill="#13151f"/>
<text x="16" y="113" font-size="13" fill="#8b8fa8">Overview</text>
<text x="16" y="153" font-size="13" fill="#8b8fa8">Device Plugins</text>
<text x="16" y="193" font-size="13" fill="#8b8fa8">GPU Nodes</text>
<text x="16" y="233" font-size="13" fill="#8b8fa8">GPU Pods</text>
<rect x="0" y="250" width="200" height="36" fill="#a78bfa22"/>
<text x="16" y="273" font-size="13" fill="#a78bfa" font-weight="600">Metrics</text>
<!-- Page title -->
<text x="220" y="90" font-size="22" font-weight="700" fill="#ffffff">GPU Metrics</text>
<text x="220" y="112" font-size="13" fill="#8b8fa8">Real-time GPU power draw from node-exporter i915 hwmon (discrete GPU nodes only)</text>
<!-- Time range selector -->
<rect x="980" y="72" width="200" height="30" rx="6" fill="#1e2130"/>
<text x="1000" y="92" font-size="13" fill="#8b8fa8">Last 30 min ▾</text>
<!-- Summary stat cards -->
<rect x="220" y="130" width="230" height="80" rx="8" fill="#1e2130"/>
<text x="240" y="158" font-size="12" fill="#8b8fa8">Peak Power Draw</text>
<text x="240" y="195" font-size="28" font-weight="700" fill="#f87171">186 W</text>
<text x="360" y="195" font-size="12" fill="#8b8fa8">gpu-node-03</text>
<rect x="466" y="130" width="230" height="80" rx="8" fill="#1e2130"/>
<text x="486" y="158" font-size="12" fill="#8b8fa8">Avg Power (cluster)</text>
<text x="486" y="195" font-size="28" font-weight="700" fill="#fb923c">124 W</text>
<text x="606" y="195" font-size="12" fill="#8b8fa8">3 nodes</text>
<rect x="712" y="130" width="230" height="80" rx="8" fill="#1e2130"/>
<text x="732" y="158" font-size="12" fill="#8b8fa8">Avg TDP Utilization</text>
<text x="732" y="195" font-size="28" font-weight="700" fill="#a78bfa">68%</text>
<text x="820" y="195" font-size="12" fill="#8b8fa8">of 250W TDP</text>
<rect x="958" y="130" width="210" height="80" rx="8" fill="#1e2130"/>
<text x="978" y="158" font-size="12" fill="#8b8fa8">Nodes Reporting</text>
<text x="978" y="195" font-size="28" font-weight="700" fill="#34d399">3</text>
<text x="1030" y="195" font-size="12" fill="#8b8fa8">/ 4 discrete</text>
<!-- Main chart: GPU Power Draw over time -->
<rect x="220" y="226" width="948" height="300" rx="8" fill="#1e2130"/>
<text x="240" y="256" font-size="15" font-weight="600" fill="#ffffff">GPU Power Draw (W) — Last 30 Minutes</text>
<!-- Chart area -->
<rect x="260" y="270" width="880" height="230" fill="#13151f" rx="4"/>
<!-- Y axis labels -->
<text x="250" y="498" font-size="11" fill="#6b7280" text-anchor="end">0</text>
<text x="250" y="440" font-size="11" fill="#6b7280" text-anchor="end">50</text>
<text x="250" y="382" font-size="11" fill="#6b7280" text-anchor="end">100</text>
<text x="250" y="325" font-size="11" fill="#6b7280" text-anchor="end">150</text>
<text x="250" y="278" font-size="11" fill="#6b7280" text-anchor="end">200</text>
<!-- Y grid lines -->
<line x1="260" y1="497" x2="1140" y2="497" stroke="#2d3148" stroke-width="1"/>
<line x1="260" y1="439" x2="1140" y2="439" stroke="#2d3148" stroke-width="1"/>
<line x1="260" y1="381" x2="1140" y2="381" stroke="#2d3148" stroke-width="1"/>
<line x1="260" y1="323" x2="1140" y2="323" stroke="#2d3148" stroke-width="1"/>
<line x1="260" y1="275" x2="1140" y2="275" stroke="#2d3148" stroke-width="1"/>
<!-- X axis labels -->
<text x="260" y="515" font-size="11" fill="#6b7280">-30m</text>
<text x="480" y="515" font-size="11" fill="#6b7280">-22m</text>
<text x="700" y="515" font-size="11" fill="#6b7280">-15m</text>
<text x="920" y="515" font-size="11" fill="#6b7280">-7m</text>
<text x="1120" y="515" font-size="11" fill="#6b7280">now</text>
<!-- Line for gpu-node-01 (purple) — ~186W with some variation -->
<polyline points="260,323 315,318 370,310 425,315 480,325 535,320 590,312 645,308 700,315 755,322 810,318 865,310 920,305 975,312 1030,320 1085,315 1140,318" fill="none" stroke="#a78bfa" stroke-width="2.5" stroke-linejoin="round"/>
<!-- Line for gpu-node-03 (orange) — ~124W workload burst then taper -->
<polyline points="260,381 315,370 370,355 425,340 480,330 535,328 590,335 645,340 700,345 755,350 810,355 865,345 920,338 975,340 1030,348 1085,355 1140,350" fill="none" stroke="#fb923c" stroke-width="2.5" stroke-linejoin="round"/>
<!-- Line for gpu-node-02 (blue) — ~80W relatively steady -->
<polyline points="260,429 315,427 370,425 425,430 480,432 535,428 590,426 645,422 700,425 755,428 810,430 865,427 920,423 975,428 1030,430 1085,425 1140,427" fill="none" stroke="#60a5fa" stroke-width="2.5" stroke-linejoin="round"/>
<!-- Legend -->
<line x1="240" y1="553" x2="268" y2="553" stroke="#a78bfa" stroke-width="2.5"/>
<text x="276" y="558" font-size="12" fill="#e2e4f0">gpu-node-01 (i915, 4x GPU, avg 186W)</text>
<line x1="540" y1="553" x2="568" y2="553" stroke="#fb923c" stroke-width="2.5"/>
<text x="576" y="558" font-size="12" fill="#e2e4f0">gpu-node-03 (i915, 8x GPU, avg 124W)</text>
<line x1="860" y1="553" x2="888" y2="553" stroke="#60a5fa" stroke-width="2.5"/>
<text x="896" y="558" font-size="12" fill="#e2e4f0">gpu-node-02 (Xe, 2x GPU, avg 80W)</text>
<!-- TDP bars section -->
<rect x="220" y="540" width="948" height="180" rx="8" fill="#1e2130"/>
<text x="240" y="568" font-size="15" font-weight="600" fill="#ffffff">TDP Utilization — Current</text>
<!-- Node rows -->
<!-- gpu-node-01 -->
<text x="240" y="600" font-size="13" fill="#e2e4f0">gpu-node-01</text>
<text x="440" y="600" font-size="12" fill="#8b8fa8">186W / 250W TDP</text>
<rect x="600" y="588" width="500" height="16" rx="8" fill="#2d3148"/>
<rect x="600" y="588" width="372" height="16" rx="8" fill="#a78bfa"/>
<text x="1110" y="600" font-size="12" fill="#a78bfa">74%</text>
<!-- gpu-node-03 -->
<text x="240" y="634" font-size="13" fill="#e2e4f0">gpu-node-03</text>
<text x="440" y="634" font-size="12" fill="#8b8fa8">124W / 250W TDP</text>
<rect x="600" y="622" width="500" height="16" rx="8" fill="#2d3148"/>
<rect x="600" y="622" width="248" height="16" rx="8" fill="#fb923c"/>
<text x="1110" y="634" font-size="12" fill="#fb923c">50%</text>
<!-- gpu-node-02 -->
<text x="240" y="668" font-size="13" fill="#e2e4f0">gpu-node-02</text>
<text x="440" y="668" font-size="12" fill="#8b8fa8">80W / 150W TDP</text>
<rect x="600" y="656" width="500" height="16" rx="8" fill="#2d3148"/>
<rect x="600" y="656" width="267" height="16" rx="8" fill="#60a5fa"/>
<text x="1110" y="668" font-size="12" fill="#60a5fa">53%</text>
<!-- No data node -->
<text x="240" y="702" font-size="13" fill="#6b7280">gpu-node-04</text>
<text x="440" y="702" font-size="12" fill="#6b7280">No hwmon data — integrated GPU</text>
<text x="1110" y="702" font-size="12" fill="#6b7280">n/a</text>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

View File
+83
View File
@@ -0,0 +1,83 @@
import { test as setup, expect, Page } from '@playwright/test';
const AUTH_STATE_PATH = 'e2e/.auth/state.json';
async function authenticateWithOIDC(page: Page, username: string, password: string): Promise<void> {
// Navigate to login — Headlamp redirects / to /c/main/login
await page.goto('/');
await page.waitForURL('**/login');
// Click "Sign In" and capture the Authentik popup
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: /sign in/i }).click();
const popup = await popupPromise;
// Wait for the Authentik popup to fully load before interacting
await popup.waitForLoadState('domcontentloaded');
await popup.waitForLoadState('networkidle');
// Authentik step 1: fill username — wait for the form to render
const usernameField = popup.getByRole('textbox', { name: /email or username/i });
await usernameField.waitFor({ state: 'visible', timeout: 15_000 });
await usernameField.fill(username);
await popup.getByRole('button', { name: /log in/i }).click();
// Authentik step 2: fill password — wait for the next step to load
await popup.waitForLoadState('networkidle');
const passwordField = popup.getByRole('textbox', { name: /password/i });
await passwordField.waitFor({ state: 'visible', timeout: 15_000 });
await passwordField.fill(password);
await popup.getByRole('button', { name: /continue|log in/i }).click();
// Wait for the popup to close (Authentik redirects back, Headlamp processes callback)
await popup.waitForEvent('close', { timeout: 15_000 });
// Original page should now be authenticated — wait for sidebar
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
timeout: 15_000,
});
}
async function authenticateWithToken(page: Page, token: string): Promise<void> {
await page.goto('/');
// Headlamp goes to /token directly when no OIDC is configured,
// or through /login when OIDC is configured
await page.waitForURL(/\/(login|token)$/);
if (page.url().includes('/login')) {
// OIDC login page — click "use a token" to reach token auth.
// Wait explicitly before clicking so failures surface at 15 s
// with a clear message rather than silently timing out at 60 s.
const useTokenBtn = page.getByRole('button', { name: /use a token/i });
await useTokenBtn.waitFor({ state: 'visible', timeout: 15_000 });
await useTokenBtn.click();
await page.waitForURL('**/token');
}
// Fill the "ID token" field and submit
await page.getByRole('textbox', { name: /id token/i }).fill(token);
await page.getByRole('button', { name: /authenticate/i }).click();
// Wait for the main UI to load
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
timeout: 15_000,
});
}
setup('authenticate with Headlamp', async ({ page }) => {
const username = process.env.AUTHENTIK_USERNAME;
const password = process.env.AUTHENTIK_PASSWORD;
const token = process.env.HEADLAMP_TOKEN;
if (username && password) {
await authenticateWithOIDC(page, username, password);
} else if (token) {
await authenticateWithToken(page, token);
} else {
throw new Error(
'Set AUTHENTIK_USERNAME + AUTHENTIK_PASSWORD for OIDC auth, or HEADLAMP_TOKEN for token auth'
);
}
await page.context().storageState({ path: AUTH_STATE_PATH });
});
+85
View File
@@ -0,0 +1,85 @@
import { test, expect } from '@playwright/test';
test.describe('Intel GPU plugin smoke tests', () => {
test('sidebar contains intel-gpu entry', async ({ page }) => {
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.getByRole('button', { name: 'intel-gpu' })).toBeVisible();
});
test('sidebar intel-gpu entry is clickable and navigates to overview', async ({ page }) => {
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
const gpuEntry = sidebar.getByRole('button', { name: 'intel-gpu' });
await expect(gpuEntry).toBeVisible();
await gpuEntry.click();
// Should navigate to the overview route
await expect(page).toHaveURL(/\/intel-gpu$/);
await expect(page.getByRole('heading', { name: /Intel GPU — Overview/i })).toBeVisible();
});
test('overview page renders GPU device list or empty state', async ({ page }) => {
await page.goto('/c/main/intel-gpu');
// Overview heading should be present
await expect(page.getByRole('heading', { name: /Intel GPU — Overview/i })).toBeVisible({
timeout: 15_000,
});
// Either a populated table/list or an empty-state indicator must be visible
const hasTable = await page.locator('table').first().isVisible().catch(() => false);
const hasEmptyState = await page
.locator('text=/no.*gpu|no.*device|0 node|empty/i')
.first()
.isVisible()
.catch(() => false);
expect(hasTable || hasEmptyState).toBe(true);
});
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: /Intel GPU — Device Plugins/i })).toBeVisible({
timeout: 15_000,
});
const hasTable = await page.locator('table').first().isVisible().catch(() => false);
const hasEmptyState = await page
.locator('text=/no.*plugin|no.*device|empty/i')
.first()
.isVisible()
.catch(() => false);
expect(hasTable || hasEmptyState).toBe(true);
});
test('navigation between plugin views works', async ({ page }) => {
// Headlamp sidebar child links only appear when already on a child route,
// not after clicking the parent entry from the overview. Test route
// accessibility via direct navigation — each route must render its heading.
await page.goto('/c/main/intel-gpu');
await expect(page.getByRole('heading', { name: /Intel GPU — Overview/i })).toBeVisible({
timeout: 15_000,
});
await page.goto('/c/main/intel-gpu/nodes');
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: /Intel GPU — Pods/i })).toBeVisible({ timeout: 15_000 });
await page.goto('/c/main/intel-gpu/metrics');
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 }) => {
await page.goto('/settings/plugins');
// Wait for plugin list to load — plugin scripts load asynchronously
const pluginEntry = page.locator('text=intel-gpu').first();
await expect(pluginEntry).toBeVisible({ timeout: 30_000 });
});
});
+66 -2
View File
@@ -1,15 +1,16 @@
{
"name": "intel-gpu",
"version": "0.4.3",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "intel-gpu",
"version": "0.4.3",
"version": "1.1.0",
"license": "Apache-2.0",
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
@@ -2496,6 +2497,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -13828,6 +13845,53 @@
"node": ">=8"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "intel-gpu",
"version": "0.4.3",
"version": "1.1.0",
"description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring",
"repository": {
"type": "git",
@@ -22,7 +22,9 @@
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed"
},
"peerDependencies": {
"react": "^18.0.0",
@@ -30,6 +32,7 @@
},
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: 'list',
use: {
baseURL: process.env.HEADLAMP_URL || (() => { throw new Error('HEADLAMP_URL is required — run scripts/deploy-e2e-headlamp.sh first'); })(),
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/, timeout: 60_000 },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/state.json',
},
dependencies: ['setup'],
},
],
});
+2 -16
View File
@@ -1,19 +1,5 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"baseBranches": ["main"],
"schedule": ["every weekend"],
"prConcurrentLimit": 10,
"packageRules": [
{
"matchManagers": ["npm"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "npm minor and patch"
},
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "github-actions minor and patch"
}
]
"extends": ["github>privilegedescalation/.github:renovate-config"]
}
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# deploy-e2e-headlamp.sh
#
# Deploys a stock Headlamp instance with the intel-gpu plugin loaded via
# a ConfigMap volume mount. No custom Docker images — the plugin is built
# in CI and injected as a ConfigMap.
#
# E2E resources are deployed to the `privilegedescalation-dev` namespace. Nothing
# persists beyond the test run — teardown cleans up all created resources.
#
# Prerequisites:
# - Plugin built (dist/ exists with plugin-main.js + package.json)
# - kubectl configured with cluster access
# - RBAC applied: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
#
# Environment:
# E2E_NAMESPACE — namespace for E2E Headlamp (default: privilegedescalation-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
# HEADLAMP_VERSION — Headlamp image tag (default: latest)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST_DIR="$REPO_ROOT/dist"
E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}"
if [ ! -d "$DIST_DIR" ]; then
echo "ERROR: dist/ not found. Run 'npm run build' first." >&2
exit 1
fi
# --- Preflight: verify RBAC before touching the cluster ---
echo "Checking RBAC permissions in namespace '${E2E_NAMESPACE}'..."
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 " Apply RBAC first: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml" >&2
exit 1
fi
echo "=== E2E Headlamp Deployment ==="
echo " Image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}"
echo " Namespace: $E2E_NAMESPACE"
echo " Release: $E2E_RELEASE"
# --- Create ConfigMap from built plugin ---
echo ""
echo "Creating ConfigMap with plugin files..."
# Delete existing ConfigMap if present (idempotent redeploy)
kubectl delete configmap headlamp-intel-gpu-plugin \
-n "$E2E_NAMESPACE" --ignore-not-found
# Create ConfigMap from dist/ contents and package.json
kubectl create configmap headlamp-intel-gpu-plugin \
-n "$E2E_NAMESPACE" \
--from-file="$DIST_DIR" \
--from-file=package.json="$REPO_ROOT/package.json"
# --- Tear down any existing E2E deployment for a clean start ---
echo ""
echo "Removing any existing E2E deployment (clean-start)..."
kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
# --- Deploy Headlamp via kubectl apply ---
echo ""
echo "Deploying Headlamp E2E instance..."
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
template:
metadata:
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
serviceAccountName: ${E2E_RELEASE}
automountServiceAccountToken: true
securityContext: {}
containers:
- name: headlamp
image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}
imagePullPolicy: IfNotPresent
securityContext:
runAsNonRoot: true
privileged: false
runAsUser: 100
runAsGroup: 101
args:
- "-in-cluster"
- "-in-cluster-context-name=main"
- "-plugins-dir=/headlamp/plugins"
ports:
- name: http
containerPort: 4466
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: intel-gpu-plugin
mountPath: /headlamp/plugins/headlamp-intel-gpu
readOnly: true
volumes:
- name: intel-gpu-plugin
configMap:
name: headlamp-intel-gpu-plugin
---
apiVersion: v1
kind: Service
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
EOF
echo "Waiting for rollout..."
kubectl rollout status "deployment/${E2E_RELEASE}" \
-n "$E2E_NAMESPACE" --timeout=120s
# --- Generate a service URL for tests ---
SVC_URL="http://${E2E_RELEASE}.${E2E_NAMESPACE}.svc.cluster.local"
# --- Wait for DNS and HTTP reachability ---
echo ""
echo "Waiting for ${SVC_URL} to be reachable..."
ATTEMPTS=0
MAX_ATTEMPTS=24 # 24 × 5s = 120s max
until curl -sf --max-time 5 "${SVC_URL}" -o /dev/null 2>/dev/null; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then
echo "ERROR: ${SVC_URL} not reachable after $((MAX_ATTEMPTS * 5))s" >&2
exit 1
fi
echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] not yet reachable, retrying in 5s..."
sleep 5
done
echo ""
echo "E2E Headlamp is ready at: ${SVC_URL}"
echo " export HEADLAMP_URL=${SVC_URL}"
# --- Generate a token for test auth ---
echo ""
echo "Creating service account token for E2E auth..."
kubectl create serviceaccount headlamp-e2e-test \
-n "$E2E_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
TOKEN=$(kubectl create token headlamp-e2e-test -n "$E2E_NAMESPACE" --duration=1h 2>/dev/null || echo "")
if [ -n "$TOKEN" ]; then
echo " export HEADLAMP_TOKEN=<generated>"
echo ""
echo "HEADLAMP_URL=${SVC_URL}" > "$REPO_ROOT/.env.e2e"
echo "HEADLAMP_TOKEN=${TOKEN}" >> "$REPO_ROOT/.env.e2e"
echo "Wrote .env.e2e with HEADLAMP_URL and HEADLAMP_TOKEN"
else
echo " WARNING: Could not generate token. Set HEADLAMP_TOKEN manually or use OIDC."
fi
echo ""
echo "E2E deployment complete."
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# teardown-e2e-headlamp.sh
#
# Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh.
#
# Environment:
# E2E_NAMESPACE — namespace to clean up (default: privilegedescalation-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
echo "=== E2E Headlamp Teardown ==="
echo " Namespace: $E2E_NAMESPACE"
echo " Release: $E2E_RELEASE"
echo "Removing Headlamp Deployment, Service, and ServiceAccount..."
kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
echo "Cleaning up ConfigMap..."
kubectl delete configmap headlamp-intel-gpu-plugin -n "$E2E_NAMESPACE" --ignore-not-found
echo "Cleaning up test service account..."
kubectl delete serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --ignore-not-found
# Clean up .env.e2e if present
if [ -f "$REPO_ROOT/.env.e2e" ]; then
rm "$REPO_ROOT/.env.e2e"
echo "Removed .env.e2e"
fi
echo ""
echo "E2E teardown complete."
+177
View File
@@ -0,0 +1,177 @@
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 <IntelGpuDataProvider>{children}</IntelGpuDataProvider>;
}
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] as any);
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] });
render(
<IntelGpuDataProvider>
<div data-testid="child">hello</div>
</IntelGpuDataProvider>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
it('exposes loading=true while nodes/pods are null', async () => {
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([null, null] as any);
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([null, null] as any);
// 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)] as any,
null,
] as any);
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
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] as any);
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
// 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] as any);
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
// 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] as any);
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
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);
});
});
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();
});
});
+18 -3
View File
@@ -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<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. */
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);
+173
View File
@@ -0,0 +1,173 @@
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 }) => <div data-testid="loader">{title}</div>,
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
NameValueTable: ({
rows,
}: {
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
}) => (
<dl>
{rows.map((r, i) => (
<div key={i}>
<dt>{r.name}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
),
SimpleTable: ({
columns,
data,
}: {
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
data: unknown[];
}) => (
<table>
<thead>
<tr>
{columns.map(c => (
<th key={c.label}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, i) => (
<tr key={i}>
{columns.map(c => (
<td key={c.label}>{c.getter(item)}</td>
))}
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-status={status}>{children}</span>
),
PercentageBar: () => <div data-testid="percentage-bar" />,
}));
vi.mock('../api/IntelGpuDataContext', () => ({
useIntelGpuContext: vi.fn(),
}));
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): 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(<DevicePluginsPage />);
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(<DevicePluginsPage />);
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(<DevicePluginsPage />);
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(<DevicePluginsPage />);
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(<DevicePluginsPage />);
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(<DevicePluginsPage />);
expect(screen.getByText('fetch error')).toBeInTheDocument();
});
});
+214
View File
@@ -0,0 +1,214 @@
import { 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, GpuChipMetrics, GpuMetrics } from '../api/metrics';
import MetricsPage from './MetricsPage';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
NameValueTable: ({
rows,
}: {
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
}) => (
<dl>
{rows.map((r, i) => (
<div key={i}>
<dt>{r.name}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
),
SimpleTable: ({
columns,
data,
}: {
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
data: unknown[];
}) => (
<table>
<thead>
<tr>
{columns.map(c => (
<th key={c.label}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, i) => (
<tr key={i}>
{columns.map(c => (
<td key={c.label}>{c.getter(item)}</td>
))}
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-status={status}>{children}</span>
),
PercentageBar: () => <div data-testid="percentage-bar" />,
}));
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> = {}): 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 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(<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...');
});
it('shows "Prometheus Unreachable" section when fetchGpuMetrics returns null', async () => {
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
vi.mocked(fetchGpuMetrics).mockResolvedValue(null);
render(<MetricsPage />);
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(<MetricsPage />);
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(<MetricsPage />);
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(<MetricsPage />);
// 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(<MetricsPage />);
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(<MetricsPage />);
// 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(<MetricsPage />);
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(<MetricsPage />);
await waitFor(() => {
// formatWatts mock: "45.3 W" and "120.0 W"
expect(screen.getAllByText(/45\.3 W/).length).toBeGreaterThan(0);
});
});
});
+5 -6
View File
@@ -230,10 +230,6 @@ export default function MetricsPage() {
};
}, [ctxLoading, fetchSeq]);
if (ctxLoading) {
return <Loader title="Loading Intel GPU data..." />;
}
return (
<>
<div
@@ -247,7 +243,7 @@ export default function MetricsPage() {
<SectionHeader title="Intel GPU — Metrics" />
<button
onClick={() => void doFetch()}
disabled={fetching}
disabled={fetching || ctxLoading}
aria-label="Refresh metrics"
style={{
padding: '6px 16px',
@@ -255,15 +251,18 @@ export default function MetricsPage() {
color: 'var(--mui-palette-primary-main, #0071c5)',
border: '1px solid var(--mui-palette-primary-main, #0071c5)',
borderRadius: '4px',
cursor: 'pointer',
cursor: fetching || ctxLoading ? 'not-allowed' : 'pointer',
fontSize: '13px',
fontWeight: 500,
opacity: fetching || ctxLoading ? 0.6 : 1,
}}
>
{fetching ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{ctxLoading && <Loader title="Loading Intel GPU data..." />}
<MetricRequirements />
{fetching && !metrics && <Loader title="Querying Prometheus for GPU metrics..." />}
+143
View File
@@ -0,0 +1,143 @@
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 }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
NameValueTable: ({
rows,
}: {
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
}) => (
<dl>
{rows.map((r, i) => (
<div key={i}>
<dt>{r.name}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
),
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-status={status}>{children}</span>
),
}));
vi.mock('../api/IntelGpuDataContext', () => ({
useIntelGpuContext: vi.fn(),
}));
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): 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(<NodeDetailSection resource={nonGpuNodeRaw} />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing for a non-GPU node passed via jsonData wrapper', () => {
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext());
const { container } = render(<NodeDetailSection resource={{ jsonData: nonGpuNodeRaw }} />);
expect(container).toBeEmptyDOMElement();
});
it('renders "Intel GPU" section for a GPU node provided via jsonData', () => {
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] }));
render(<NodeDetailSection resource={{ jsonData: gpuNodeRaw }} />);
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(<NodeDetailSection resource={gpuNodeRaw} />);
expect(screen.getByText('Intel GPU')).toBeInTheDocument();
});
it('renders capacity and allocatable rows', () => {
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false, gpuPods: [] }));
render(<NodeDetailSection resource={gpuNodeRaw} />);
// 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(<NodeDetailSection resource={gpuNodeRaw} />);
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(<NodeDetailSection resource={gpuNodeRaw} />);
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(<NodeDetailSection resource={gpuNodeRaw} />);
expect(screen.getByText('my-gpu-pod')).toBeInTheDocument();
});
});
+153
View File
@@ -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 }) => <div data-testid="loader">{title}</div>,
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
NameValueTable: ({
rows,
}: {
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
}) => (
<dl>
{rows.map((r, i) => (
<div key={i}>
<dt>{r.name}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
),
SimpleTable: ({
columns,
data,
}: {
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
data: unknown[];
}) => (
<table>
<thead>
<tr>
{columns.map(c => (
<th key={c.label}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, i) => (
<tr key={i}>
{columns.map(c => (
<td key={c.label}>{c.getter(item)}</td>
))}
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-status={status}>{children}</span>
),
PercentageBar: () => <div data-testid="percentage-bar" />,
}));
vi.mock('../api/IntelGpuDataContext', () => ({
useIntelGpuContext: vi.fn(),
}));
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): 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(<NodesPage />);
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(<NodesPage />);
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(<NodesPage />);
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(<NodesPage />);
// 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(<NodesPage />);
expect(screen.getByText('node fetch failed')).toBeInTheDocument();
});
});
+198
View File
@@ -0,0 +1,198 @@
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 }) => <div data-testid="loader">{title}</div>,
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
NameValueTable: ({
rows,
}: {
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
}) => (
<dl>
{rows.map((r, i) => (
<div key={i}>
<dt>{r.name}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
),
SimpleTable: ({
columns,
data,
}: {
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
data: unknown[];
}) => (
<table>
<thead>
<tr>
{columns.map(c => (
<th key={c.label}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, i) => (
<tr key={i}>
{columns.map(c => (
<td key={c.label}>{c.getter(item)}</td>
))}
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-status={status}>{children}</span>
),
PercentageBar: () => <div data-testid="percentage-bar" />,
}));
vi.mock('../api/IntelGpuDataContext', () => ({
useIntelGpuContext: vi.fn(),
}));
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): 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(<OverviewPage />);
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(<OverviewPage />);
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(<OverviewPage />);
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(<OverviewPage />);
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(<OverviewPage />);
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(<OverviewPage />);
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(<OverviewPage />);
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(<OverviewPage />);
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(<OverviewPage />);
expect(screen.getByText('Active GPU Pods')).toBeInTheDocument();
expect(screen.getByText('workload-pod-1')).toBeInTheDocument();
});
});
+138
View File
@@ -0,0 +1,138 @@
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 }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
NameValueTable: ({
rows,
}: {
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
}) => (
<dl>
{rows.map((r, i) => (
<div key={i}>
<dt>{r.name}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
),
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-status={status}>{children}</span>
),
}));
// 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(<PodDetailSection resource={nonGpuPodRaw} />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing for a non-GPU pod passed via jsonData', () => {
const { container } = render(<PodDetailSection resource={{ jsonData: nonGpuPodRaw }} />);
expect(container).toBeEmptyDOMElement();
});
it('renders "Intel GPU Resources" section for a GPU-requesting pod via jsonData', () => {
render(<PodDetailSection resource={{ jsonData: gpuPodRaw }} />);
expect(screen.getByText('Intel GPU Resources')).toBeInTheDocument();
});
it('renders "Intel GPU Resources" section for a GPU-requesting pod provided directly', () => {
render(<PodDetailSection resource={gpuPodRaw} />);
expect(screen.getByText('Intel GPU Resources')).toBeInTheDocument();
});
it('shows container GPU resource request rows', () => {
render(<PodDetailSection resource={gpuPodRaw} />);
// Row label: "{containerName} → {resourceName} request"
expect(screen.getByText('trainer → GPU (i915) request')).toBeInTheDocument();
});
it('shows phase status label for Running phase', () => {
render(<PodDetailSection resource={gpuPodRaw} />);
const statusEl = screen.getByText('Running');
expect(statusEl).toHaveAttribute('data-status', 'success');
});
it('shows phase status label for Pending phase', () => {
render(<PodDetailSection resource={gpuPodLimitsOnly} />);
const statusEl = screen.getByText('Pending');
expect(statusEl).toHaveAttribute('data-status', 'warning');
});
it('still renders when a container has limits only and no requests', () => {
render(<PodDetailSection resource={gpuPodLimitsOnly} />);
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(<PodDetailSection resource={gpuPodRaw} />);
expect(screen.getByText('gpu-node-1')).toBeInTheDocument();
});
it('shows GPU container count', () => {
render(<PodDetailSection resource={gpuPodRaw} />);
const label = screen.getByText('GPU Containers');
expect(label).toBeInTheDocument();
// The value '1' is rendered in the sibling <dd>; verify via parent row
expect(label.closest('div')).toHaveTextContent('1');
});
});
+170
View File
@@ -0,0 +1,170 @@
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 }) => <div data-testid="loader">{title}</div>,
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
NameValueTable: ({
rows,
}: {
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
}) => (
<dl>
{rows.map((r, i) => (
<div key={i}>
<dt>{r.name}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
),
SimpleTable: ({
columns,
data,
}: {
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
data: unknown[];
}) => (
<table>
<thead>
<tr>
{columns.map(c => (
<th key={c.label}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, i) => (
<tr key={i}>
{columns.map(c => (
<td key={c.label}>{c.getter(item)}</td>
))}
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-status={status}>{children}</span>
),
PercentageBar: () => <div data-testid="percentage-bar" />,
}));
vi.mock('../api/IntelGpuDataContext', () => ({
useIntelGpuContext: vi.fn(),
}));
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): 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(<PodsPage />);
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(<PodsPage />);
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(<PodsPage />);
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(<PodsPage />);
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(<PodsPage />);
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(<PodsPage />);
expect(screen.getByText('All GPU Pods')).toBeInTheDocument();
expect(screen.getByText('my-workload')).toBeInTheDocument();
});
});
+3
View File
@@ -6,5 +6,8 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
env: {
NODE_ENV: 'test',
},
},
});