Compare commits

..

31 Commits

Author SHA1 Message Date
Gandalf the Greybeard a05c42afd9 fix: update package-lock.json with @playwright/test dependencies
PR #25 added @playwright/test to devDependencies but package-lock.json
was not updated, causing npm ci to fail on all CI runs.

Fixes #30

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 23:27:56 +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
github-actions[bot] 60d76f1cb2 release: v0.4.3 2026-03-19 21:39:48 +00:00
privilegedescalation-paperclip[bot] 0d72d07048 fix: add pull-requests write permission to release workflow (#13)
The reusable release workflow declares pull-requests:write but the
caller didn't grant it, causing startup_failure on GitHub Actions.

Co-authored-by: Hugh Hackman [bot] <hugh-hackman[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:33:16 +00:00
gandalf-the-greybeard[bot] daad91880c fix: add missing devDependencies for CI (#12)
The package.json only listed @kinvolk/headlamp-plugin as a devDependency,
but CI runs tsc, eslint, prettier, and vitest which all require additional
packages. Add the same devDependencies used by the reference kube-vip plugin
and regenerate the lock file.

Also adds peerDependencies for react/react-dom to match the reference plugin
conventions.

Co-authored-by: Gandalf the Greybeard <gandalf-the-greybeard[bot]@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-03-18 23:30:28 +00:00
null-pointer-nancy[bot] b9137958f0 Merge pull request #11 from privilegedescalation/fix/dep-security-overrides-tar-undici
fix: add npm overrides for tar and undici security advisories
2026-03-18 23:14:06 +00:00
Hugh Hackman 37a2232178 fix: regenerate package-lock.json for undici override
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 23:08:00 +00:00
Hugh Hackman 56eb0761dd fix: add npm overrides for tar and undici security advisories
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 22:55:50 +00:00
null-pointer-nancy[bot] 18c6a03c0c Merge pull request #9 from privilegedescalation/docs/remove-manual-install
docs: remove manual install sections from README
2026-03-17 12:19:29 +00:00
Gandalf the Greybeard cbd86f696d docs: remove manual install sections from README
ArtifactHub plugin installer is the only supported installation method.
Remove manual tarball, sidecar, and build-from-source install options
to align documentation with company policy.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 12:15:45 +00:00
null-pointer-nancy[bot] 510affbe1a ci: retrigger after shared workflow fix (#8)
CI retrigger after shared workflow fix (.github PR#14)
2026-03-15 17:54:40 +00:00
Chris Farhood fcb2e5f9fd Merge pull request #7 from privilegedescalation/policy/artifacthub-only
policy: add ArtifactHub-only installation requirement
2026-03-15 12:43:25 -04:00
null-pointer-nancy[bot] a34802b477 policy: add ArtifactHub-only installation policy
Per CEO directive, ArtifactHub via the Headlamp plugin installer is the
only approved installation method. No exceptions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-15 16:36:41 +00:00
gandalf-the-greybeard[bot] e5e681b415 fix: rename plugin from headlamp-intel-gpu to intel-gpu (#6)
Aligns naming convention across all plugins. Renames package, sidebar entries, routes, and documentation references.
2026-03-10 23:49:08 +00:00
30 changed files with 3294 additions and 607 deletions
+18
View File
@@ -0,0 +1,18 @@
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
+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
+4
View File
@@ -10,10 +10,14 @@ on:
permissions:
contents: write
pull-requests: write
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/
+1 -1
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Headlamp plugin for Intel GPU device plugin visibility and monitoring. Read-only — monitors GpuDevicePlugin CRDs, GPU-capable nodes, pods requesting Intel GPU resources, and real-time power metrics via Prometheus. No cluster write operations.
- **Plugin name**: `headlamp-intel-gpu`
- **Plugin name**: `intel-gpu`
- **Target**: Headlamp >= v0.20.0
- **Data sources**: GpuDevicePlugin CRDs (`deviceplugin.intel.com/v1`), Nodes, Pods (all namespaces), Prometheus (node-exporter i915 hwmon)
- **Reference plugin**: `../headlamp-kube-vip-plugin`
+24
View File
@@ -0,0 +1,24 @@
# Installation Policy
## Approved Installation Method
**The ONLY approved method for installing this plugin is via [Artifact Hub](https://artifacthub.io/) using the Headlamp plugin installer.**
No other installation method is acceptable. This includes but is not limited to:
- Direct installation from GitHub release assets
- Manual npm pack / tarball extraction
- initContainer workarounds that bypass Artifact Hub
- Direct file copy or sidecar injection
## Enforcement
All deployment configurations, CI/CD pipelines, and documentation MUST reference Artifact Hub as the sole plugin distribution channel. Any pull request that introduces an alternative installation method will be rejected.
## Rationale
Artifact Hub provides verified checksums, consistent versioning, and a standard discovery mechanism for the CNCF ecosystem. Bypassing it introduces security and integrity risks.
---
*This policy is set by the CTO and approved by the CEO of Privileged Escalation.*
+1 -23
View File
@@ -18,29 +18,7 @@ A [Headlamp](https://headlamp.dev/) plugin providing visibility into [Intel GPU
## Installation
### Plugin Manager (Headlamp UI)
Search for `headlamp-intel-gpu` in the Headlamp Plugin Manager.
### Manual
```bash
# Download the latest release tarball
curl -LO https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/latest/download/headlamp-intel-gpu-*.tar.gz
# Extract to Headlamp plugins directory
mkdir -p ~/.config/Headlamp/plugins
tar -xzf headlamp-intel-gpu-*.tar.gz -C ~/.config/Headlamp/plugins/
```
### From Source
```bash
git clone https://github.com/privilegedescalation/headlamp-intel-gpu-plugin.git
cd headlamp-intel-gpu-plugin
npm install
npm run build
```
Search for `headlamp-intel-gpu` in the Headlamp Plugin Manager (Settings → Plugins → Catalog).
## Requirements
+41 -3
View File
@@ -1,4 +1,4 @@
version: "0.4.2"
version: "1.0.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.2/headlamp-intel-gpu-0.4.2.tar.gz"
headlamp/plugin/archive-checksum: sha256:0713b099a79ed63ea30675fee96f2a70e37471507d4135b529df158a09960492
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v1.0.0/intel-gpu-1.0.0.tar.gz"
headlamp/plugin/archive-checksum: sha256:93d6c531e7c12440c9625138f0645fc0c3521b574d0089492759699b324943f0
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 });
});
+95
View File
@@ -0,0 +1,95 @@
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/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/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: /device plugin/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 }) => {
await page.goto('/c/main/intel-gpu');
await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible({
timeout: 15_000,
});
// Navigate to GPU Nodes
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
const nodesLink = sidebar.getByRole('link', { name: /gpu nodes/i });
await expect(nodesLink).toBeVisible();
await nodesLink.click();
await expect(page).toHaveURL(/\/intel-gpu\/nodes$/);
await expect(page.getByRole('heading', { name: /node/i })).toBeVisible();
// Navigate to GPU Pods
const podsLink = sidebar.getByRole('link', { name: /gpu pods/i });
await expect(podsLink).toBeVisible();
await podsLink.click();
await expect(page).toHaveURL(/\/intel-gpu\/pods$/);
await expect(page.getByRole('heading', { name: /pod/i })).toBeVisible();
// Navigate to Metrics
const metricsLink = sidebar.getByRole('link', { name: /metrics/i });
await expect(metricsLink).toBeVisible();
await metricsLink.click();
await expect(page).toHaveURL(/\/intel-gpu\/metrics$/);
await expect(page.getByRole('heading', { name: /metric/i })).toBeVisible();
});
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 });
});
});
+866 -526
View File
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -1,6 +1,6 @@
{
"name": "headlamp-intel-gpu",
"version": "0.4.2",
"name": "intel-gpu",
"version": "1.0.0",
"description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring",
"repository": {
"type": "git",
@@ -22,9 +22,28 @@
"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",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0"
"@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",
"jsdom": "^24.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^5.3.0",
"vitest": "^3.0.5"
},
"overrides": {
"tar": "^7.5.11",
"undici": "^7.24.3"
}
}
+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."
+154
View File
@@ -0,0 +1,154 @@
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { IntelGpuDataProvider, useIntelGpuContext } from './IntelGpuDataContext';
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
K8s: {
ResourceClasses: {
Node: { useList: vi.fn() },
Pod: { useList: vi.fn() },
},
},
ApiProxy: { request: vi.fn() },
}));
// Minimal GPU node fixture
const gpuNodeRaw = {
metadata: {
name: 'gpu-node-1',
uid: 'uid-001',
labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' },
},
status: {
capacity: { 'gpu.intel.com/i915': '1' },
allocatable: { 'gpu.intel.com/i915': '1' },
},
};
// Minimal GPU plugin CRD fixture
const gpuDevicePluginRaw = {
kind: 'GpuDevicePlugin',
metadata: { name: 'gpu-plugin-default', uid: 'uid-dp-001' },
spec: {},
};
function makeNodeWrapper(raw: unknown) {
return { jsonData: raw };
}
function Wrapper({ children }: { children: React.ReactNode }) {
return <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);
});
});
});
+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();
});
});
+212
View File
@@ -0,0 +1,212 @@
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', () => {
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true }));
// fetchGpuMetrics should never be called in loading state
vi.mocked(fetchGpuMetrics).mockResolvedValue(null);
render(<MetricsPage />);
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);
});
});
});
+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();
});
});
+34 -34
View File
@@ -1,5 +1,5 @@
/**
* headlamp-intel-gpu-plugin — entry point.
* intel-gpu-plugin — entry point.
*
* Registers sidebar entries, routes, detail view sections, and table column
* processors for Intel GPU device plugin visibility in Headlamp.
@@ -34,49 +34,49 @@ import PodsPage from './components/PodsPage';
registerSidebarEntry({
parent: null,
name: 'headlamp-intel-gpu',
label: 'headlamp-intel-gpu',
url: '/headlamp-intel-gpu',
name: 'intel-gpu',
label: 'intel-gpu',
url: '/intel-gpu',
icon: 'mdi:gpu',
});
registerSidebarEntry({
parent: 'headlamp-intel-gpu',
name: 'headlamp-intel-gpu-overview',
parent: 'intel-gpu',
name: 'intel-gpu-overview',
label: 'Overview',
url: '/headlamp-intel-gpu',
url: '/intel-gpu',
icon: 'mdi:view-dashboard',
});
registerSidebarEntry({
parent: 'headlamp-intel-gpu',
name: 'headlamp-intel-gpu-device-plugins',
parent: 'intel-gpu',
name: 'intel-gpu-device-plugins',
label: 'Device Plugins',
url: '/headlamp-intel-gpu/device-plugins',
url: '/intel-gpu/device-plugins',
icon: 'mdi:chip',
});
registerSidebarEntry({
parent: 'headlamp-intel-gpu',
name: 'headlamp-intel-gpu-nodes',
parent: 'intel-gpu',
name: 'intel-gpu-nodes',
label: 'GPU Nodes',
url: '/headlamp-intel-gpu/nodes',
url: '/intel-gpu/nodes',
icon: 'mdi:server',
});
registerSidebarEntry({
parent: 'headlamp-intel-gpu',
name: 'headlamp-intel-gpu-pods',
parent: 'intel-gpu',
name: 'intel-gpu-pods',
label: 'GPU Pods',
url: '/headlamp-intel-gpu/pods',
url: '/intel-gpu/pods',
icon: 'mdi:cube-outline',
});
registerSidebarEntry({
parent: 'headlamp-intel-gpu',
name: 'headlamp-intel-gpu-metrics',
parent: 'intel-gpu',
name: 'intel-gpu-metrics',
label: 'Metrics',
url: '/headlamp-intel-gpu/metrics',
url: '/intel-gpu/metrics',
icon: 'mdi:chart-line',
});
@@ -85,9 +85,9 @@ registerSidebarEntry({
// ---------------------------------------------------------------------------
registerRoute({
path: '/headlamp-intel-gpu',
sidebar: 'headlamp-intel-gpu-overview',
name: 'headlamp-intel-gpu-overview',
path: '/intel-gpu',
sidebar: 'intel-gpu-overview',
name: 'intel-gpu-overview',
exact: true,
component: () => (
<IntelGpuDataProvider>
@@ -97,9 +97,9 @@ registerRoute({
});
registerRoute({
path: '/headlamp-intel-gpu/device-plugins',
sidebar: 'headlamp-intel-gpu-device-plugins',
name: 'headlamp-intel-gpu-device-plugins',
path: '/intel-gpu/device-plugins',
sidebar: 'intel-gpu-device-plugins',
name: 'intel-gpu-device-plugins',
exact: true,
component: () => (
<IntelGpuDataProvider>
@@ -109,9 +109,9 @@ registerRoute({
});
registerRoute({
path: '/headlamp-intel-gpu/nodes',
sidebar: 'headlamp-intel-gpu-nodes',
name: 'headlamp-intel-gpu-nodes',
path: '/intel-gpu/nodes',
sidebar: 'intel-gpu-nodes',
name: 'intel-gpu-nodes',
exact: true,
component: () => (
<IntelGpuDataProvider>
@@ -121,9 +121,9 @@ registerRoute({
});
registerRoute({
path: '/headlamp-intel-gpu/pods',
sidebar: 'headlamp-intel-gpu-pods',
name: 'headlamp-intel-gpu-pods',
path: '/intel-gpu/pods',
sidebar: 'intel-gpu-pods',
name: 'intel-gpu-pods',
exact: true,
component: () => (
<IntelGpuDataProvider>
@@ -133,9 +133,9 @@ registerRoute({
});
registerRoute({
path: '/headlamp-intel-gpu/metrics',
sidebar: 'headlamp-intel-gpu-metrics',
name: 'headlamp-intel-gpu-metrics',
path: '/intel-gpu/metrics',
sidebar: 'intel-gpu-metrics',
name: 'intel-gpu-metrics',
exact: true,
component: () => (
<IntelGpuDataProvider>
+3
View File
@@ -6,5 +6,8 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
env: {
NODE_ENV: 'test',
},
},
});