Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e9db29e1d | |||
| 46a46593f9 | |||
| 9754e324c7 | |||
| f56750911f | |||
| 8017a2231a | |||
| 424b83c8d7 | |||
| bbd5990af3 | |||
| b3234bddc7 | |||
| fb639f4736 | |||
| b99be4f461 | |||
| 7508058f84 | |||
| c65d792a01 | |||
| aff63c4541 | |||
| 2c117eff9f | |||
| 32d825e441 | |||
| c7920b5b8e | |||
| c99e235caa | |||
| 85c839bc19 | |||
| 00c29e36dd | |||
| 823e590513 | |||
| 3cc0094842 | |||
| 161d817e6c | |||
| 375f43265d | |||
| b81f25ad74 | |||
| ca430b8b03 | |||
| e139999f20 | |||
| d4ac2b2f23 | |||
| 15320dbcba | |||
| 82ad1faa33 | |||
| 547f743016 | |||
| aceb06f2e5 | |||
| fcb72d344c | |||
| 673949f361 | |||
| eed5724d5f | |||
| 0c7e096231 | |||
| 796ec48ad1 | |||
| fc592e9e38 | |||
| 6057c81402 | |||
| f547348ef7 | |||
| cd55d1bbba | |||
| 4cace284a4 | |||
| 46821c747c | |||
| e3c17c9380 | |||
| fbd8e27a56 | |||
| e0ebd38653 | |||
| 6d889494c4 | |||
| 6cd159b5a4 | |||
| 8ec38cb247 | |||
| e77f075521 | |||
| 60d76f1cb2 | |||
| 0d72d07048 | |||
| daad91880c | |||
| b9137958f0 | |||
| 37a2232178 | |||
| 56eb0761dd | |||
| 18c6a03c0c | |||
| cbd86f696d | |||
| 510affbe1a | |||
| fcb2e5f9fd | |||
| a34802b477 | |||
| e5e681b415 | |||
| db896a8f88 | |||
| a16df9baf7 | |||
| 865168285e | |||
| 84af42147f | |||
| b0de53577a | |||
| 231cb41d06 | |||
| 0e895c1b61 | |||
| 89e9b510d2 | |||
| 9d41af375e | |||
| b0b768783a | |||
| c2cbbcc14d | |||
| e17875a659 | |||
| 1ae6e2d355 | |||
| e451e3906e |
@@ -0,0 +1,241 @@
|
|||||||
|
---
|
||||||
|
name: artifacthub-headlamp
|
||||||
|
description: Use when working with ArtifactHub metadata, releases, or publishing for Headlamp plugins. Covers artifacthub-repo.yml, artifacthub-pkg.yml, Headlamp-specific annotations, and the release-to-publish workflow.
|
||||||
|
tools: Read, Write, Edit, Glob, Grep, Bash
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an expert in publishing Headlamp Kubernetes dashboard plugins to ArtifactHub. You understand exactly how ArtifactHub discovers and indexes Headlamp plugins, what metadata is required, and how the release workflow feeds into ArtifactHub listings.
|
||||||
|
|
||||||
|
Before editing any metadata files, read the existing `artifacthub-repo.yml`, `artifacthub-pkg.yml`, and `package.json` to understand the current state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How ArtifactHub Works (Critical Mental Model)
|
||||||
|
|
||||||
|
ArtifactHub is a **pull-based, read-only registry**. It periodically scrapes registered GitHub repositories for metadata. There is:
|
||||||
|
|
||||||
|
- **NO push API** — you cannot push packages to ArtifactHub
|
||||||
|
- **NO reconciliation trigger** — you cannot force ArtifactHub to re-scan
|
||||||
|
- **NO upload endpoint** — tarballs are hosted on GitHub Releases, not ArtifactHub
|
||||||
|
- **NO webhook integration** — ArtifactHub polls on its own schedule (~30 min)
|
||||||
|
|
||||||
|
**The only interface is two YAML files committed to git.** ArtifactHub reads them, and that's it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Registration
|
||||||
|
|
||||||
|
### artifacthub-repo.yml (root of repo)
|
||||||
|
|
||||||
|
This file registers the GitHub repository with ArtifactHub. Created once, rarely changed.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Artifact Hub repository metadata file
|
||||||
|
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml
|
||||||
|
repositoryID: <uuid> # Assigned by ArtifactHub when you add the repo via the web UI
|
||||||
|
owners:
|
||||||
|
- name: <github-username-or-org>
|
||||||
|
email: <email>
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to get the repositoryID:**
|
||||||
|
1. Log into artifacthub.io
|
||||||
|
2. Go to Control Panel → Repositories → Add
|
||||||
|
3. Select repository kind: "Headlamp plugins"
|
||||||
|
4. Provide the GitHub repo URL
|
||||||
|
5. ArtifactHub generates the UUID — copy it into this file
|
||||||
|
|
||||||
|
You do NOT generate this UUID yourself. It comes from ArtifactHub's web UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package Metadata
|
||||||
|
|
||||||
|
### artifacthub-pkg.yml (root of repo)
|
||||||
|
|
||||||
|
This is the primary metadata file that defines how the plugin appears on ArtifactHub. Updated with each release.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "X.Y.Z" # MUST match package.json version
|
||||||
|
name: <package-name> # npm package name from package.json
|
||||||
|
displayName: <Human Readable Name> # Shown on ArtifactHub listing
|
||||||
|
createdAt: "YYYY-MM-DDTHH:MM:SSZ" # ISO 8601 — update each release
|
||||||
|
description: >-
|
||||||
|
Multi-line description of what the plugin does.
|
||||||
|
Be specific about features and requirements.
|
||||||
|
license: Apache-2.0
|
||||||
|
homeURL: https://github.com/<owner>/<repo>
|
||||||
|
appVersion: "X.Y.Z" # Version of upstream project (optional)
|
||||||
|
category: <category> # See categories below
|
||||||
|
keywords:
|
||||||
|
- headlamp
|
||||||
|
- kubernetes
|
||||||
|
- <plugin-specific>
|
||||||
|
maintainers:
|
||||||
|
- name: <name>
|
||||||
|
email: <email>
|
||||||
|
provider:
|
||||||
|
name: <name>
|
||||||
|
links:
|
||||||
|
- name: GitHub
|
||||||
|
url: https://github.com/<owner>/<repo>
|
||||||
|
- name: Issues
|
||||||
|
url: https://github.com/<owner>/<repo>/issues
|
||||||
|
changes: # Changelog for this version
|
||||||
|
- kind: added|changed|fixed|removed
|
||||||
|
description: "What changed"
|
||||||
|
annotations: # CRITICAL — Headlamp-specific
|
||||||
|
headlamp/plugin/archive-url: "https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz"
|
||||||
|
headlamp/plugin/archive-checksum: "sha256:<checksum>"
|
||||||
|
headlamp/plugin/version-compat: ">=X.Y.Z"
|
||||||
|
headlamp/plugin/distro-compat: "<targets>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Headlamp-Specific Annotations (Required)
|
||||||
|
|
||||||
|
These annotations in `artifacthub-pkg.yml` are what make ArtifactHub treat the package as a Headlamp plugin:
|
||||||
|
|
||||||
|
### headlamp/plugin/archive-url
|
||||||
|
**Required.** Direct download URL to the plugin tarball on GitHub Releases.
|
||||||
|
|
||||||
|
Format: `https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz`
|
||||||
|
|
||||||
|
- The tarball is built by `npx @kinvolk/headlamp-plugin build` and then `npx @kinvolk/headlamp-plugin package`
|
||||||
|
- The `<pkgname>` comes from `package.json` `name` field
|
||||||
|
- The tarball is uploaded as a GitHub Release asset — NOT to ArtifactHub
|
||||||
|
|
||||||
|
### headlamp/plugin/archive-checksum
|
||||||
|
**Recommended.** SHA256 checksum of the tarball.
|
||||||
|
|
||||||
|
Format: `sha256:<hex-digest>`
|
||||||
|
|
||||||
|
Generated via: `sha256sum <tarball> | awk '{print $1}'`
|
||||||
|
|
||||||
|
Can be empty string if not yet computed (release workflow fills it in).
|
||||||
|
|
||||||
|
### headlamp/plugin/version-compat
|
||||||
|
**Required.** Minimum Headlamp version the plugin works with.
|
||||||
|
|
||||||
|
Format: `>=X.Y.Z` (e.g., `>=0.20.0`, `>=0.26`)
|
||||||
|
|
||||||
|
### headlamp/plugin/distro-compat
|
||||||
|
**Required.** Comma-separated list of supported Headlamp deployment targets.
|
||||||
|
|
||||||
|
Valid values:
|
||||||
|
- `in-cluster` — Headlamp running inside a Kubernetes cluster
|
||||||
|
- `web` — Web-based Headlamp deployment
|
||||||
|
- `app` — Headlamp desktop application (Electron)
|
||||||
|
- `desktop` — Alias for desktop app
|
||||||
|
- `docker-desktop` — Docker Desktop Headlamp extension
|
||||||
|
|
||||||
|
Example: `"in-cluster,web,app"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ArtifactHub Categories
|
||||||
|
|
||||||
|
Valid `category` values for Headlamp plugins:
|
||||||
|
- `security` — Secrets, RBAC, policy enforcement
|
||||||
|
- `storage` — CSI drivers, persistent volumes, Ceph/Rook
|
||||||
|
- `monitoring-logging` — Metrics, GPU monitoring, observability
|
||||||
|
- `networking` — Load balancers, virtual IPs, ingress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional Fields
|
||||||
|
|
||||||
|
### containersImages
|
||||||
|
For plugins associated with a specific container/operator:
|
||||||
|
```yaml
|
||||||
|
containersImages:
|
||||||
|
- name: <component-name>
|
||||||
|
image: docker.io/<org>/<image>:<tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
### recommendations
|
||||||
|
Link to related ArtifactHub packages:
|
||||||
|
```yaml
|
||||||
|
recommendations:
|
||||||
|
- url: https://artifacthub.io/packages/helm/<repo>/<chart>
|
||||||
|
```
|
||||||
|
|
||||||
|
### install
|
||||||
|
Custom installation instructions (markdown):
|
||||||
|
```yaml
|
||||||
|
install: |
|
||||||
|
## Install via Headlamp Plugin Manager
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### logoPath
|
||||||
|
Path to a logo image file in the repo (relative to root).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Release → ArtifactHub Pipeline
|
||||||
|
|
||||||
|
This is the actual flow. There is NO other way to publish:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Developer triggers release workflow (workflow_dispatch with version)
|
||||||
|
2. CI runs tests
|
||||||
|
3. Workflow updates:
|
||||||
|
- package.json (npm version)
|
||||||
|
- artifacthub-pkg.yml (version, archive-url, checksum, createdAt, changes)
|
||||||
|
4. Plugin is built: npx @kinvolk/headlamp-plugin build
|
||||||
|
5. Plugin is packaged: creates <pkgname>-<version>.tar.gz
|
||||||
|
6. SHA256 checksum is computed and written to artifacthub-pkg.yml
|
||||||
|
7. Changes committed to main
|
||||||
|
8. Git tag created: v<version>
|
||||||
|
9. GitHub Release created with tarball attached
|
||||||
|
10. ArtifactHub polls the repo (~30 min) and picks up the new metadata
|
||||||
|
11. Plugin appears/updates on artifacthub.io
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- Steps 1-9 happen in your GitHub Actions workflow
|
||||||
|
- Step 10 is entirely controlled by ArtifactHub — you cannot trigger it
|
||||||
|
- The tarball lives on GitHub Releases, not ArtifactHub
|
||||||
|
- ArtifactHub only reads `artifacthub-pkg.yml` to discover the download URL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes to Avoid
|
||||||
|
|
||||||
|
1. **Trying to push/trigger ArtifactHub** — There is no API for this. Just commit metadata and wait.
|
||||||
|
2. **Version mismatch** — `version` in `artifacthub-pkg.yml` MUST match `package.json`. The release workflow should update both.
|
||||||
|
3. **Wrong archive-url** — Must point to the actual GitHub Release asset URL. Verify the tarball filename matches what the build produces.
|
||||||
|
4. **Missing checksum** — While optional, missing checksums may cause warnings. The release workflow should compute and write it.
|
||||||
|
5. **Forgetting createdAt** — Must be updated each release. ArtifactHub uses this for sorting.
|
||||||
|
6. **Stale changes section** — The `changes` list should reflect the current version's changelog only, not cumulative history.
|
||||||
|
7. **Assuming ArtifactHub hosts anything** — It's an index/catalog. All artifacts are hosted elsewhere (GitHub Releases).
|
||||||
|
8. **Trying to generate repositoryID** — This UUID comes from ArtifactHub's web UI when you register the repo. Don't make one up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tarball Structure
|
||||||
|
|
||||||
|
The plugin tarball built by `@kinvolk/headlamp-plugin` contains:
|
||||||
|
|
||||||
|
```
|
||||||
|
<pkgname>/
|
||||||
|
main.js # Bundled plugin code
|
||||||
|
package.json # Plugin metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<pkgname>` directory inside the tarball matches the `name` field from `package.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validating Metadata
|
||||||
|
|
||||||
|
Before committing, check:
|
||||||
|
1. `version` matches across `package.json` and `artifacthub-pkg.yml`
|
||||||
|
2. `archive-url` version tag matches the `version` field
|
||||||
|
3. `name` in `artifacthub-pkg.yml` matches `package.json` `name`
|
||||||
|
4. `createdAt` is a valid ISO 8601 timestamp
|
||||||
|
5. All required annotations are present
|
||||||
|
6. `changes` entries use valid `kind` values: `added`, `changed`, `fixed`, `removed`
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
---
|
||||||
|
name: headlamp-plugin-developer
|
||||||
|
description: Use when building, extending, debugging, or reviewing Headlamp Kubernetes dashboard plugins. Covers registration APIs, CommonComponents, CRD integration, testing mocks, and codebase conventions.
|
||||||
|
tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a senior Headlamp plugin engineer. You produce code matching this codebase's exact conventions. Before writing new code, read `CLAUDE.md` and review existing files in `src/` to understand established patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin Registration Functions
|
||||||
|
|
||||||
|
All from `@kinvolk/headlamp-plugin/lib`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
registerRoute({
|
||||||
|
path: string; // React Router path (e.g., '/myresource/:namespace?/:name?')
|
||||||
|
sidebar?: string; // Sidebar entry name to highlight
|
||||||
|
component: () => JSX.Element; // Arrow function wrapper required
|
||||||
|
exact?: boolean;
|
||||||
|
name?: string; // Used by Link's routeName prop
|
||||||
|
}): void
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: string | null; // null = top-level
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
icon?: string; // Iconify ID (e.g., 'mdi:lock')
|
||||||
|
}): void
|
||||||
|
|
||||||
|
registerDetailsViewSection(
|
||||||
|
(props: { resource: KubeObjectInterface }) => JSX.Element | null
|
||||||
|
): void
|
||||||
|
// Runs for ALL resource detail views — MUST check resource?.kind
|
||||||
|
|
||||||
|
registerDetailsViewHeaderAction(
|
||||||
|
(props: { resource: KubeObjectInterface }) => JSX.Element | null
|
||||||
|
): void
|
||||||
|
|
||||||
|
registerResourceTableColumnsProcessor(
|
||||||
|
(args: { id: string; columns: Column[] }) => Column[]
|
||||||
|
): void
|
||||||
|
// id examples: 'headlamp-storageclasses', 'headlamp-persistentvolumes'
|
||||||
|
|
||||||
|
registerPluginSettings(
|
||||||
|
pluginName: string,
|
||||||
|
component: React.ComponentType<{
|
||||||
|
data?: Record<string, string | number | boolean>;
|
||||||
|
onDataChange?: (data: Record<string, string | number | boolean>) => void;
|
||||||
|
}>,
|
||||||
|
showSaveButton?: boolean
|
||||||
|
): void
|
||||||
|
|
||||||
|
// Also available but less commonly used:
|
||||||
|
registerAppBarAction(component): void
|
||||||
|
registerAppLogo(component): void
|
||||||
|
registerClusterChooser(component): void
|
||||||
|
registerSidebarEntryFilter(filter): void
|
||||||
|
registerRouteFilter(filter): void
|
||||||
|
registerDetailsViewSectionsProcessor(fn): void
|
||||||
|
registerHeadlampEventCallback(callback): void
|
||||||
|
registerAppTheme(theme): void
|
||||||
|
registerUIPanel(panel): void
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## K8s Module
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||||
|
```
|
||||||
|
|
||||||
|
### KubeObject Base Class
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class KubeObject<T extends KubeObjectInterface> {
|
||||||
|
jsonData: T; // Raw K8s JSON — use this for spec/status access
|
||||||
|
metadata: KubeMetadata;
|
||||||
|
kind: string;
|
||||||
|
|
||||||
|
getAge(): string;
|
||||||
|
getName(): string;
|
||||||
|
getNamespace(): string | undefined;
|
||||||
|
delete(force?: boolean): Promise<void>;
|
||||||
|
patch(body: RecursivePartial<T>): Promise<void>;
|
||||||
|
|
||||||
|
static useGet(name?, namespace?): [item: T | null, error: ApiError | null];
|
||||||
|
static useList(opts?: { namespace?: string }): [items: T[], error: ApiError | null, loading: boolean];
|
||||||
|
static apiEndpoint: ApiClient | ApiWithNamespaceClient;
|
||||||
|
static className: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL**: Resource hooks return class instances. Raw K8s JSON lives under `.jsonData`. Access fields via `.jsonData.spec`, `.jsonData.status`, or typed getters.
|
||||||
|
|
||||||
|
### ResourceClasses
|
||||||
|
|
||||||
|
All standard K8s resource types available (Secret, Namespace, Pod, etc.):
|
||||||
|
```typescript
|
||||||
|
const [secrets, error, loading] = K8s.ResourceClasses.Secret.useList({ namespace: 'default' });
|
||||||
|
const [secret, error] = K8s.ResourceClasses.Secret.useGet('my-secret', 'default');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ApiProxy
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||||
|
|
||||||
|
ApiProxy.request(
|
||||||
|
path: string,
|
||||||
|
options?: {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
body?: string; // JSON.stringify'd
|
||||||
|
isJSON?: boolean; // false for non-JSON (logs, metrics)
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
): Promise<unknown>
|
||||||
|
|
||||||
|
// CRD endpoint factories
|
||||||
|
ApiProxy.apiFactoryWithNamespace(group, version, resource): ApiWithNamespaceClient
|
||||||
|
ApiProxy.apiFactory(group, version, resource): ApiClient
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service proxy URL** (accessing in-cluster services):
|
||||||
|
```
|
||||||
|
/api/v1/namespaces/${ns}/services/http:${name}:${port}/proxy${path}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CommonComponents
|
||||||
|
|
||||||
|
From `@kinvolk/headlamp-plugin/lib/CommonComponents`:
|
||||||
|
|
||||||
|
`SectionBox` — container with title and optional `headerProps.actions`
|
||||||
|
`SectionHeader` — standalone header with title and actions array
|
||||||
|
`SectionFilterHeader` — header with namespace filter; `noNamespaceFilter` to hide it; `actions` array
|
||||||
|
`StatusLabel` — status chip; `status`: `'success' | 'error' | 'warning' | 'info'`
|
||||||
|
`Link` — internal nav; `routeName` + `params` object
|
||||||
|
`Loader` — spinner with `title` prop
|
||||||
|
`PercentageBar` — bar chart with `data` array of `{ name, value, fill }`
|
||||||
|
|
||||||
|
### SimpleTable (non-obvious props)
|
||||||
|
```typescript
|
||||||
|
<SimpleTable
|
||||||
|
data={items}
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (item) => item.metadata.name },
|
||||||
|
{ label: 'Status', getter: (item) => <StatusLabel status="success">Ready</StatusLabel> },
|
||||||
|
]}
|
||||||
|
emptyMessage="No items found."
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### NameValueTable (non-obvious props)
|
||||||
|
```typescript
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Key', value: 'display value' },
|
||||||
|
{ name: 'Hidden', value: 'x', hide: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ConfigStore
|
||||||
|
```typescript
|
||||||
|
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
|
||||||
|
const store = new ConfigStore<MyConfig>('plugin-name');
|
||||||
|
store.get(): MyConfig;
|
||||||
|
store.update(partial: Partial<MyConfig>): void;
|
||||||
|
store.useConfig(): () => MyConfig;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-bundled (no package.json entry needed)
|
||||||
|
react, react-dom, react-router-dom, @iconify/react, react-redux, @material-ui/core, @material-ui/styles, lodash, notistack, recharts, monaco-editor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRD Class Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||||
|
const { apiFactoryWithNamespace } = ApiProxy;
|
||||||
|
const { KubeObject } = K8s.cluster;
|
||||||
|
type KubeObjectInterface = K8s.cluster.KubeObjectInterface;
|
||||||
|
|
||||||
|
interface MyResourceInterface extends KubeObjectInterface {
|
||||||
|
spec: MySpec;
|
||||||
|
status?: MyStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MyResource extends KubeObject<MyResourceInterface> {
|
||||||
|
static apiEndpoint = apiFactoryWithNamespace('mygroup.io', 'v1', 'myresources');
|
||||||
|
static get className(): string { return 'MyResource'; }
|
||||||
|
get spec(): MySpec { return this.jsonData.spec; }
|
||||||
|
get status(): MyStatus | undefined { return this.jsonData.status; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin Entry Point Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Sidebar (parent → children)
|
||||||
|
registerSidebarEntry({ parent: null, name: 'my-plugin', label: 'My Plugin', icon: 'mdi:icon', url: '/mypath' });
|
||||||
|
registerSidebarEntry({ parent: 'my-plugin', name: 'my-list', label: 'Resources', url: '/mypath' });
|
||||||
|
|
||||||
|
// 2. Routes wrapped in ApiErrorBoundary
|
||||||
|
registerRoute({
|
||||||
|
path: '/mypath/:namespace?/:name?',
|
||||||
|
sidebar: 'my-list',
|
||||||
|
component: () => <ApiErrorBoundary><MyListPage /></ApiErrorBoundary>,
|
||||||
|
exact: true, name: 'my-resource',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Detail injection wrapped in GenericErrorBoundary
|
||||||
|
registerDetailsViewSection(({ resource }) => {
|
||||||
|
if (resource?.kind !== 'Secret') return null;
|
||||||
|
return <GenericErrorBoundary><MySection resource={resource} /></GenericErrorBoundary>;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Settings
|
||||||
|
registerPluginSettings('my-plugin', SettingsPage, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Headlamp Test Mocks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||||
|
ApiProxy: { request: vi.fn().mockResolvedValue({}) },
|
||||||
|
K8s: { ResourceClasses: {}, cluster: { KubeObject: class {} } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||||
|
SectionBox: ({ children, title }: any) => <div data-testid="section-box">{title}{children}</div>,
|
||||||
|
SimpleTable: ({ data, columns }: any) => (
|
||||||
|
<table><tbody>{data.map((d: any, i: number) =>
|
||||||
|
<tr key={i}>{columns.map((c: any, j: number) => <td key={j}>{c.getter(d)}</td>)}</tr>
|
||||||
|
)}</tbody></table>
|
||||||
|
),
|
||||||
|
NameValueTable: ({ rows }: any) => (
|
||||||
|
<dl>{rows.filter((r: any) => !r.hide).map((r: any) =>
|
||||||
|
<div key={r.name}><dt>{r.name}</dt><dd>{r.value}</dd></div>
|
||||||
|
)}</dl>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ children, status }: any) => <span data-status={status}>{children}</span>,
|
||||||
|
Link: ({ children }: any) => <a>{children}</a>,
|
||||||
|
Loader: ({ title }: any) => <div data-testid="loader">{title}</div>,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theming & Dark Mode
|
||||||
|
|
||||||
|
Headlamp supports light and dark themes. **Never hardcode colors.** Use CSS custom properties with light-mode fallbacks:
|
||||||
|
|
||||||
|
### Required CSS variables for inline styles
|
||||||
|
```typescript
|
||||||
|
// Text
|
||||||
|
color: 'var(--mui-palette-text-primary)'
|
||||||
|
color: 'var(--mui-palette-text-secondary, #666)'
|
||||||
|
|
||||||
|
// Backgrounds
|
||||||
|
backgroundColor: 'var(--mui-palette-background-default, #fafafa)'
|
||||||
|
backgroundColor: 'var(--mui-palette-background-paper, #fff)'
|
||||||
|
|
||||||
|
// Borders
|
||||||
|
border: '1px solid var(--mui-palette-divider, #e0e0e0)'
|
||||||
|
|
||||||
|
// Interactive
|
||||||
|
backgroundColor: 'var(--mui-palette-primary-main, #1976d2)'
|
||||||
|
color: 'var(--mui-palette-primary-contrastText, #fff)'
|
||||||
|
|
||||||
|
// Disabled states
|
||||||
|
backgroundColor: 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||||
|
color: 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||||
|
|
||||||
|
// Links
|
||||||
|
color: 'var(--link-color, #1976d2)'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common mistakes to avoid
|
||||||
|
- **NEVER** use raw `#fff`, `#000`, `#333`, `#666` etc. without wrapping in `var(--mui-palette-*)`
|
||||||
|
- **NEVER** use `rgba(0,0,0,0.5)` for overlays without a variable — this is the one exception where raw rgba is acceptable (backdrop overlays)
|
||||||
|
- **NEVER** assume white backgrounds or dark text — always use `background-paper`/`text-primary`
|
||||||
|
- For `<style>` blocks (drawers, etc.), use the same CSS variables in the stylesheet
|
||||||
|
- Fallback values after the comma are for environments where the variable isn't set — always use the light-mode default
|
||||||
|
|
||||||
|
### Form inputs in custom components
|
||||||
|
```typescript
|
||||||
|
const inputStyle = {
|
||||||
|
border: '1px solid var(--mui-palette-divider, #ccc)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: 'var(--mui-palette-background-paper)',
|
||||||
|
color: 'var(--mui-palette-text-primary)',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Quality Rules
|
||||||
|
|
||||||
|
1. **Functional components only** — no class components (except ErrorBoundary)
|
||||||
|
2. **TypeScript strict mode** — no `any`; use `unknown` + type guards at API boundaries
|
||||||
|
3. **Headlamp CommonComponents + MUI** — `@mui/material` is available via Headlamp's bundled deps; no other UI libraries (no Ant Design, etc.)
|
||||||
|
4. **Inline CSS only** — `style={{}}` props, CSS variables (`var(--mui-palette-*)`) for theming
|
||||||
|
5. **Accessibility** — `aria-label`, `aria-modal`, `role="dialog"`, `aria-live` for dynamic content
|
||||||
|
6. **Cancellation safety** — async effects must check a `cancelled` flag
|
||||||
|
7. **Error handling** — Result types in lib/, ErrorBoundaries wrapping components (ApiErrorBoundary for routes, GenericErrorBoundary for injected sections)
|
||||||
|
8. **Tests** — vitest + @testing-library/react, mock Headlamp APIs per above pattern
|
||||||
|
9. Run `npm run tsc` and `npm test` after implementation changes
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['@headlamp-k8s/eslint-config'],
|
extends: ['@headlamp-k8s/eslint-config'],
|
||||||
|
rules: {
|
||||||
|
// Prettier handles indentation; the shared config's indent rule
|
||||||
|
// conflicts with Prettier's JSX ternary formatting.
|
||||||
|
indent: 'off',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [privilegedescalation]
|
||||||
+183
-13
@@ -2,40 +2,210 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: ['**']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, dev, uat]
|
||||||
workflow_call:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: local-ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
container: node:22-slim
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Install Python
|
||||||
uses: actions/setup-node@v4
|
run: apt-get update && apt-get install -y --no-install-recommends python3 python3-yaml
|
||||||
|
|
||||||
|
- name: Validate artifacthub-pkg.yml
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import sys, re
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
print("::warning::PyYAML not available, skipping artifacthub-pkg.yml validation")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open("artifacthub-pkg.yml") as f:
|
||||||
|
pkg = yaml.safe_load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("::error::artifacthub-pkg.yml not found")
|
||||||
|
sys.exit(1)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
print(f"::error::artifacthub-pkg.yml is invalid YAML: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for field in ["version", "name", "description", "homeURL"]:
|
||||||
|
if not pkg.get(field):
|
||||||
|
errors.append(f"Missing required field: {field}")
|
||||||
|
|
||||||
|
version = pkg.get("version", "")
|
||||||
|
if version and not re.match(r'^\d+\.\d+\.\d+$', str(version)):
|
||||||
|
errors.append(f"version '{version}' is not SemVer (expected X.Y.Z)")
|
||||||
|
|
||||||
|
annotations = pkg.get("annotations", {}) or {}
|
||||||
|
archive_url = annotations.get("headlamp/plugin/archive-url", "")
|
||||||
|
archive_checksum = annotations.get("headlamp/plugin/archive-checksum", "")
|
||||||
|
|
||||||
|
if not archive_url:
|
||||||
|
errors.append("Missing annotation: headlamp/plugin/archive-url")
|
||||||
|
if not archive_checksum:
|
||||||
|
errors.append("Missing annotation: headlamp/plugin/archive-checksum")
|
||||||
|
elif not re.match(r'^sha256:[0-9a-f]{64}$', str(archive_checksum)):
|
||||||
|
errors.append(f"archive-checksum has unexpected format: '{archive_checksum}' (expected sha256:<64 hex chars>)")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for e in errors:
|
||||||
|
print(f"::error::{e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"artifacthub-pkg.yml valid: name={pkg['name']} version={pkg['version']}")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Detect package manager
|
||||||
|
id: pkg-manager
|
||||||
|
run: |
|
||||||
|
if [ -f "pnpm-lock.yaml" ]; then
|
||||||
|
echo "manager=pnpm" >> $GITHUB_OUTPUT
|
||||||
|
PM=$(python3 -c "import json,sys; d=json.load(open('package.json')); print('true' if d.get('packageManager','').startswith('pnpm@') else 'false')" 2>/dev/null || echo "false")
|
||||||
|
echo "has_package_manager=$PM" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "manager=npm" >> $GITHUB_OUTPUT
|
||||||
|
echo "has_package_manager=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: ${{ steps.pkg-manager.outputs.manager == 'npm' && 'npm' || '' }}
|
||||||
|
|
||||||
|
- name: Setup pnpm (via Corepack, reads version from packageManager field)
|
||||||
|
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'true'
|
||||||
|
run: |
|
||||||
|
npm install -g corepack
|
||||||
|
corepack enable pnpm
|
||||||
|
corepack install
|
||||||
|
|
||||||
|
- name: Setup pnpm (version latest)
|
||||||
|
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'false'
|
||||||
|
uses: pnpm/action-setup@v5
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-store
|
||||||
|
if: steps.pkg-manager.outputs.manager == 'pnpm'
|
||||||
|
run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Cache pnpm store
|
||||||
|
if: steps.pkg-manager.outputs.manager == 'pnpm'
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-store.outputs.dir }}
|
||||||
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
|
- name: Validate pnpm lockfile freshness
|
||||||
|
if: steps.pkg-manager.outputs.manager == 'pnpm'
|
||||||
|
run: |
|
||||||
|
if [ ! -f "pnpm-lock.yaml" ]; then
|
||||||
|
echo "No pnpm-lock.yaml found, skipping lockfile freshness check"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! grep -q 'overrides:' pnpm-lock.yaml 2>/dev/null; then
|
||||||
|
echo "No overrides section in pnpm-lock.yaml, skipping lockfile freshness check"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Detected pnpm-lock.yaml with overrides section. Checking lockfile freshness..."
|
||||||
|
ERR_FILE=$(mktemp)
|
||||||
|
if pnpm install --frozen-lockfile 2>&1 | tee "$ERR_FILE"; then
|
||||||
|
echo "Lockfile is fresh."
|
||||||
|
else
|
||||||
|
if grep -q "CONFIG_MISMATCH\|EBADLOCKFILE\|ERR_PNPM_LOCKFILE" "$ERR_FILE"; then
|
||||||
|
echo ""
|
||||||
|
echo "::error::pnpm-lock.yaml is out of sync with package.json overrides."
|
||||||
|
echo "::error::Run 'pnpm install' to regenerate the lockfile and commit the updated pnpm-lock.yaml."
|
||||||
|
rm -f "$ERR_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$ERR_FILE"
|
||||||
|
echo "::warning::Install failed with a different error. Will retry in the Install dependencies step."
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: |
|
||||||
|
max_attempts=3
|
||||||
|
attempt=1
|
||||||
|
while [ $attempt -le $max_attempts ]; do
|
||||||
|
echo "Attempt $attempt of $max_attempts"
|
||||||
|
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||||
|
pnpm install --frozen-lockfile && break
|
||||||
|
else
|
||||||
|
npm ci && break
|
||||||
|
fi
|
||||||
|
if [ $attempt -lt $max_attempts ]; then
|
||||||
|
echo "::warning::Install step failed on attempt $attempt. Retrying in 5 seconds..."
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
if [ $attempt -gt $max_attempts ]; then
|
||||||
|
echo "::error::Install step failed after $max_attempts attempts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build plugin
|
- name: Build plugin
|
||||||
run: npx @kinvolk/headlamp-plugin build
|
run: npx @kinvolk/headlamp-plugin build
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: |
|
||||||
|
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||||
|
pnpm run lint
|
||||||
|
else
|
||||||
|
npm run lint
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Type-check
|
- name: Type-check
|
||||||
run: npm run tsc
|
run: |
|
||||||
|
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||||
|
pnpm run tsc
|
||||||
|
else
|
||||||
|
npm run tsc
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: npm run format:check
|
run: |
|
||||||
|
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||||
|
pnpm run format:check
|
||||||
|
else
|
||||||
|
npm run format:check
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: |
|
||||||
|
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||||
|
pnpm test
|
||||||
|
else
|
||||||
|
npm test
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Security audit
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||||
|
npx audit-ci --pnpm --audit-level=high --config ./audit-ci.jsonc
|
||||||
|
else
|
||||||
|
npx audit-ci --npm --audit-level=high --config ./audit-ci.jsonc
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
name: Promotion Gate
|
||||||
|
|
||||||
|
# dev PRs: no gate (engineer self-merges).
|
||||||
|
# uat PRs: QA approval required.
|
||||||
|
# main PRs: UAT approval required (uat→main promotions).
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted, dismissed]
|
||||||
|
pull_request:
|
||||||
|
branches: [uat, main]
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
promotion-gate:
|
||||||
|
name: Promotion Gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: ubuntu:latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install dependencies
|
||||||
|
run: apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates curl jq
|
||||||
|
|
||||||
|
- name: Check promotion approval
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BASE_REF: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
if [ -z "${PR_NUMBER}" ] || [ "${PR_NUMBER}" = "null" ]; then
|
||||||
|
echo "::notice::No PR number in context. Skipping promotion gate."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking promotion gate for PR #${PR_NUMBER} targeting ${BASE_REF} in ${REPO}"
|
||||||
|
|
||||||
|
if [ -z "${BASE_REF}" ] && [ -n "${PR_NUMBER}" ] && [ "${PR_NUMBER}" != "null" ]; then
|
||||||
|
BASE_REF=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.base.ref')
|
||||||
|
echo "BASE_REF was empty; resolved from PR #${PR_NUMBER} API: ${BASE_REF}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine required reviewer based on target branch
|
||||||
|
case "${BASE_REF}" in
|
||||||
|
dev)
|
||||||
|
echo "Target is dev — no review required. Engineers self-merge."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
uat)
|
||||||
|
REQUIRED_REVIEWER="pe_regina"
|
||||||
|
GATE_NAME="QA"
|
||||||
|
;;
|
||||||
|
main)
|
||||||
|
REQUIRED_REVIEWER="pe_regina"
|
||||||
|
GATE_NAME="QA"
|
||||||
|
# For plugin repos (Pipeline A), UAT approval is needed for uat→main
|
||||||
|
# Check if the source branch is uat
|
||||||
|
SOURCE_REF=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.head.ref')
|
||||||
|
|
||||||
|
if [ "${SOURCE_REF}" = "uat" ]; then
|
||||||
|
REQUIRED_REVIEWER="pe_patty"
|
||||||
|
GATE_NAME="UAT"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::notice::Target branch '${BASE_REF}' has no promotion gate configured."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Required reviewer: ${REQUIRED_REVIEWER} (${GATE_NAME})"
|
||||||
|
|
||||||
|
# For uat→main promotions, pe_patty may not be able to review (bot account).
|
||||||
|
# Accept pe_nancy (CTO) as a valid alternative reviewer.
|
||||||
|
ALT_REVIEWER=""
|
||||||
|
if [ "${REQUIRED_REVIEWER}" = "pe_patty" ]; then
|
||||||
|
ALT_REVIEWER="pe_nancy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
REVIEWS=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}/reviews")
|
||||||
|
|
||||||
|
if [ -z "${REVIEWS}" ] || [ "${REVIEWS}" = "null" ]; then
|
||||||
|
echo "::warning::Could not fetch reviews for PR #${PR_NUMBER}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${REQUIRED_REVIEWER}" \
|
||||||
|
'[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end')
|
||||||
|
|
||||||
|
echo "${GATE_NAME} (${REQUIRED_REVIEWER}) approved: ${REVIEWER_APPROVED}"
|
||||||
|
|
||||||
|
# Fallback: check if CTO approved as alternative for uat→main
|
||||||
|
if [ "${REVIEWER_APPROVED}" != "true" ] && [ -n "${ALT_REVIEWER}" ]; then
|
||||||
|
REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${ALT_REVIEWER}" \
|
||||||
|
'[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end')
|
||||||
|
if [ "${REVIEWER_APPROVED}" = "true" ]; then
|
||||||
|
echo "CTO (${ALT_REVIEWER}) approved as fallback for UAT gate."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${REVIEWER_APPROVED}" = "true" ]; then
|
||||||
|
echo "Promotion gate passed: ${GATE_NAME} has approved."
|
||||||
|
else
|
||||||
|
echo "Promotion gate failed: waiting for ${GATE_NAME} approval from ${REQUIRED_REVIEWER}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -10,95 +10,14 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
concurrency:
|
|
||||||
group: release
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
|
||||||
uses: ./.github/workflows/ci.yaml
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: ci
|
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
|
||||||
runs-on: local-ubuntu-latest
|
secrets:
|
||||||
timeout-minutes: 10
|
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
|
||||||
|
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||||
steps:
|
with:
|
||||||
- name: Validate version format
|
version: ${{ inputs.version }}
|
||||||
run: |
|
upstream-repo: 'intel/intel-device-plugins-for-kubernetes'
|
||||||
if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
||||||
echo "Error: Version must be in X.Y.Z format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Configure Git
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Update version in package.json
|
|
||||||
run: npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
|
|
||||||
|
|
||||||
- name: Update artifacthub-pkg.yml
|
|
||||||
run: |
|
|
||||||
VERSION="${{ inputs.version }}"
|
|
||||||
PKG_NAME=$(jq -r .name package.json)
|
|
||||||
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${PKG_NAME}-${VERSION}.tar.gz"
|
|
||||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" artifacthub-pkg.yml
|
|
||||||
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build plugin
|
|
||||||
run: npx @kinvolk/headlamp-plugin build
|
|
||||||
|
|
||||||
- name: Package plugin
|
|
||||||
run: npx @kinvolk/headlamp-plugin package
|
|
||||||
|
|
||||||
- name: Prepare release tarball
|
|
||||||
run: |
|
|
||||||
VERSION="${{ inputs.version }}"
|
|
||||||
PKG_NAME=$(jq -r .name package.json)
|
|
||||||
TARBALL="${PKG_NAME}-${VERSION}.tar.gz"
|
|
||||||
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
|
|
||||||
echo "PKG_NAME=$PKG_NAME" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Validate tarball
|
|
||||||
run: |
|
|
||||||
echo "Tarball: ${{ env.TARBALL }}"
|
|
||||||
ls -lh "${{ env.TARBALL }}"
|
|
||||||
tar -tzf "${{ env.TARBALL }}" | head -20
|
|
||||||
tar -tzf "${{ env.TARBALL }}" | grep -q "main.js" || { echo "Error: main.js not found in tarball"; exit 1; }
|
|
||||||
|
|
||||||
- name: Compute checksum
|
|
||||||
run: |
|
|
||||||
CHECKSUM=$(sha256sum "${{ env.TARBALL }}" | awk '{print $1}')
|
|
||||||
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
|
|
||||||
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
|
|
||||||
|
|
||||||
- name: Commit and tag
|
|
||||||
run: |
|
|
||||||
VERSION="${{ inputs.version }}"
|
|
||||||
git add package.json package-lock.json artifacthub-pkg.yml
|
|
||||||
git commit -m "release: v${VERSION}"
|
|
||||||
git tag "v${VERSION}"
|
|
||||||
git push origin main --tags
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: v${{ inputs.version }}
|
|
||||||
name: v${{ inputs.version }}
|
|
||||||
generate_release_notes: true
|
|
||||||
files: ${{ env.TARBALL }}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
.playwright-mcp/
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ src/
|
|||||||
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerResourceTableColumnsProcessor
|
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerResourceTableColumnsProcessor
|
||||||
├── api/
|
├── api/
|
||||||
│ ├── k8s.ts # Types + helpers (GpuDevicePlugin CRD, Nodes, Pods, type guards, formatters)
|
│ ├── k8s.ts # Types + helpers (GpuDevicePlugin CRD, Nodes, Pods, type guards, formatters)
|
||||||
│ ├── k8s.test.ts # Tests for k8s helpers (70+ test cases)
|
│ ├── k8s.test.ts # Tests for k8s helpers (48 test cases)
|
||||||
│ ├── metrics.ts # Prometheus GPU power metrics (node-exporter i915 hwmon)
|
│ ├── metrics.ts # Prometheus GPU power metrics (node-exporter i915 hwmon)
|
||||||
│ └── IntelGpuDataContext.tsx # Shared React context provider with data fetching
|
│ └── IntelGpuDataContext.tsx # Shared React context provider with data fetching
|
||||||
└── components/
|
└── components/
|
||||||
@@ -44,7 +44,7 @@ src/
|
|||||||
├── NodesPage.tsx # Per-node GPU type, device count, allocation, workload pods
|
├── NodesPage.tsx # Per-node GPU type, device count, allocation, workload pods
|
||||||
├── PodsPage.tsx # All pods requesting Intel GPU resources with per-container detail
|
├── PodsPage.tsx # All pods requesting Intel GPU resources with per-container detail
|
||||||
├── MetricsPage.tsx # Real-time GPU power metrics from Prometheus
|
├── MetricsPage.tsx # Real-time GPU power metrics from Prometheus
|
||||||
├── NodeDetailSection.tsx # Injected into native Node detail page (capacity, utilization, pods)
|
├── NodeDetailSection.tsx # Injected into native Node detail page (capacity, utilization, pods)
|
||||||
├── PodDetailSection.tsx # Injected into native Pod detail page (GPU requests per container)
|
├── PodDetailSection.tsx # Injected into native Pod detail page (GPU requests per container)
|
||||||
└── integrations/
|
└── integrations/
|
||||||
└── NodeColumns.tsx # GPU Type and GPU Devices columns for native Nodes table
|
└── NodeColumns.tsx # GPU Type and GPU Devices columns for native Nodes table
|
||||||
|
|||||||
@@ -18,29 +18,10 @@ A [Headlamp](https://headlamp.dev/) plugin providing visibility into [Intel GPU
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Plugin Manager (Headlamp UI)
|
Search for `headlamp-intel-gpu` in the Headlamp Plugin Manager (Settings → Plugins → Catalog).
|
||||||
|
|
||||||
Search for `intel-gpu` in the Headlamp Plugin Manager.
|
> See [Plugin Installation Policy](https://git.farh.net/privilegedescalation/privilegedescalation.com/wiki/Plugin-Installation-Policy) for approved installation methods.
|
||||||
|
|
||||||
### Manual
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download the latest release tarball
|
|
||||||
curl -LO https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/latest/download/intel-gpu-*.tar.gz
|
|
||||||
|
|
||||||
# Extract to Headlamp plugins directory
|
|
||||||
mkdir -p ~/.config/Headlamp/plugins
|
|
||||||
tar -xzf 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
+57
-29
@@ -1,5 +1,5 @@
|
|||||||
version: "0.4.0"
|
version: "1.1.0"
|
||||||
name: intel-gpu
|
name: headlamp-intel-gpu
|
||||||
displayName: Intel GPU
|
displayName: Intel GPU
|
||||||
description: >-
|
description: >-
|
||||||
Headlamp plugin for Intel GPU device plugin visibility and monitoring.
|
Headlamp plugin for Intel GPU device plugin visibility and monitoring.
|
||||||
@@ -8,14 +8,44 @@ description: >-
|
|||||||
sections into native Node and Pod detail pages. Supports discrete (i915),
|
sections into native Node and Pod detail pages. Supports discrete (i915),
|
||||||
Xe, and integrated GPU nodes with graceful degradation when the device
|
Xe, and integrated GPU nodes with graceful degradation when the device
|
||||||
plugin operator is not installed. Includes a Metrics page showing real-time
|
plugin operator is not installed. Includes a Metrics page showing real-time
|
||||||
engine utilization, GPU frequency, VRAM usage, and energy from the device
|
GPU power draw and TDP from node-exporter i915 hwmon metrics (discrete GPU
|
||||||
plugin's Prometheus endpoint.
|
nodes only).
|
||||||
createdAt: "2026-02-18T00:00:00Z"
|
createdAt: "2026-02-18T00:00:00Z"
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
category: monitoring-logging
|
category: monitoring-logging
|
||||||
|
|
||||||
homeURL: https://github.com/privilegedescalation/headlamp-intel-gpu-plugin
|
homeURL: https://github.com/privilegedescalation/headlamp-intel-gpu-plugin
|
||||||
appVersion: "0.3.0"
|
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:
|
keywords:
|
||||||
- headlamp
|
- headlamp
|
||||||
@@ -45,33 +75,31 @@ links:
|
|||||||
url: https://intel.github.io/intel-device-plugins-for-kubernetes/
|
url: https://intel.github.io/intel-device-plugins-for-kubernetes/
|
||||||
|
|
||||||
changes:
|
changes:
|
||||||
- kind: added
|
- kind: fixed
|
||||||
description: "Metrics page: document which metrics require what infrastructure (power via hwmon works out of the box; frequency and utilization need custom exporters)"
|
description: "Remove unsafe `as any` casts in NodeDetailSection"
|
||||||
- kind: added
|
- kind: fixed
|
||||||
description: "Metrics page: real-time GPU power draw (W) and TDP via node-exporter i915 hwmon metrics in kube-prometheus-stack"
|
description: "Fix MetricsPage fetch cancellation safety (prevent setState on unmounted component)"
|
||||||
|
- kind: fixed
|
||||||
|
description: "Fix typo gpuPluinPods → gpuPluginPods in data context"
|
||||||
- kind: changed
|
- kind: changed
|
||||||
description: "Sidebar label changed to intel-gpu"
|
description: "Move extractJsonData utility to module scope to avoid recreation on every render"
|
||||||
- kind: removed
|
- kind: removed
|
||||||
description: "Removed app bar health badge"
|
description: "Remove dead AppBarGpuBadge component"
|
||||||
- kind: added
|
- kind: fixed
|
||||||
description: "Overview dashboard: plugin health, GPU node summary, allocation bar, active GPU pods"
|
description: "Fix appVersion mismatch and inaccurate metrics description in Artifact Hub metadata"
|
||||||
- kind: added
|
- kind: fixed
|
||||||
description: "Device Plugins page: GpuDevicePlugin CRD instances with spec/status and daemon pods"
|
description: "Resolve ESLint/Prettier indent conflict by disabling ESLint indent rule (Prettier is formatting authority)"
|
||||||
- kind: added
|
|
||||||
description: "GPU Nodes page: per-node GPU type, device count, allocation, workload pods"
|
screenshots:
|
||||||
- kind: added
|
- title: Overview — cluster GPU summary, operator status, and active workloads
|
||||||
description: "GPU Pods page: all pods requesting Intel GPU resources with per-container detail"
|
url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/01-overview.svg
|
||||||
- kind: added
|
- title: GPU Nodes — per-node GPU type, capacity, and allocation bars
|
||||||
description: "Node detail injection: Intel GPU section on native Node detail pages (capacity, allocatable, utilization, active pods)"
|
url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/02-nodes.svg
|
||||||
- kind: added
|
- title: Metrics — real-time GPU power draw and TDP utilization (discrete GPUs)
|
||||||
description: "Pod detail injection: GPU resource requests/limits per container on native Pod detail pages"
|
url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/03-metrics.svg
|
||||||
- kind: added
|
|
||||||
description: "Nodes table: GPU Type and GPU Devices columns injected into native Nodes table"
|
|
||||||
- kind: added
|
|
||||||
description: "App bar health badge: hidden when no Intel GPU plugin detected"
|
|
||||||
|
|
||||||
annotations:
|
annotations:
|
||||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v0.4.0/intel-gpu-0.4.0.tar.gz"
|
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v1.1.0/intel-gpu-1.1.0.tar.gz"
|
||||||
headlamp/plugin/archive-checksum: sha256:f529794d7995b35b954fa32c10874fa8367f6f5cd8040600e47a3013373219df
|
headlamp/plugin/archive-checksum: sha256:e212381f38c331383604b06f6552997fcba5c8b42a3bd828e3b43ed3e5028448
|
||||||
headlamp/plugin/version-compat: ">=0.20.0"
|
headlamp/plugin/version-compat: ">=0.20.0"
|
||||||
headlamp/plugin/distro-compat: "in-cluster,web,app"
|
headlamp/plugin/distro-compat: "in-cluster,web,app"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Artifact Hub repository metadata
|
# Artifact Hub repository metadata
|
||||||
repositoryID: c927788f-9d34-49d9-a18c-e6f78951bdfd
|
repositoryID: 3c97f78a-26e3-4e8a-89e7-29884602e3d7
|
||||||
|
|
||||||
owners:
|
owners:
|
||||||
- name: privilegedescalation
|
- name: privilegedescalation
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// Allowlist for inherited dev-dependency CVEs from @kinvolk/headlamp-plugin
|
||||||
|
// CTO decision (PRI-854): these high-severity vulns are dev/build-time only,
|
||||||
|
// trace to @kinvolk/headlamp-plugin transitive deps (Picomatch, Vite, lodash),
|
||||||
|
// and do NOT ship in production plugin artifacts.
|
||||||
|
"allowlist": [
|
||||||
|
{
|
||||||
|
"id": "GHSA-hhpm-516h-p3p6",
|
||||||
|
"reason": "Picomatch ReDoS: devDependency only, does not ship in production plugin bundle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "GHSA-36xf-7xpp-53w5",
|
||||||
|
"reason": "Vite arbitrary file read: devDependency only, does not ship in production plugin bundle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "GHSA-jf8v-p3pp-93qh",
|
||||||
|
"reason": "lodash code injection via _.template: devDependency only, does not ship in production plugin bundle"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# ADR 001: React Context for Centralized GPU State
|
||||||
|
|
||||||
|
**Status**: Accepted
|
||||||
|
|
||||||
|
**Date**: 2026-03-05
|
||||||
|
|
||||||
|
**Deciders**: Development Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Intel GPU plugin needs to share GPU-related data across 5 page views (Overview, DevicePlugins, Nodes, Pods, Metrics) and 2 detail view sections (Node, Pod). Data includes GPU nodes (identified by node labels and capacity fields), GPU pods, GpuDevicePlugin CRD instances, and plugin DaemonSet pods.
|
||||||
|
|
||||||
|
The `IntelGpuDataProvider` context holds all derived GPU state. Child components access data via `useIntelGpuContext()`. The context collects errors from three streams (node hook error, pod hook error, async CRD fetch error) into a `string[]` joined with `';'` into a single error string.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use a single `IntelGpuDataProvider` React Context that wraps every route and every `registerDetailsViewSection` call in `index.tsx`. All GPU-derived state is computed in the provider and exposed via context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- ✅ Single source of truth for all GPU data
|
||||||
|
- ✅ All views share consistent state
|
||||||
|
- ✅ Error aggregation from multiple sources into a unified error string
|
||||||
|
- ✅ Refresh mechanism updates everything atomically
|
||||||
|
- ⚠️ All consumers re-render on any data change
|
||||||
|
- ⚠️ Monolithic provider couples all GPU state together
|
||||||
|
|
||||||
|
The negative consequences are mitigated by the fact that GPU data updates infrequently in practice, so unnecessary re-renders are rare.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
1. **Per-page data fetching** — Rejected. Would duplicate complex GPU node/pod filtering logic across each of the 5 pages and 2 detail sections.
|
||||||
|
|
||||||
|
2. **Multiple contexts (NodesContext, PodsContext, CRDContext)** — Rejected. GPU data is highly cross-referenced (e.g., GPU pods reference GPU nodes, CRD instances relate to DaemonSet pods). Splitting contexts would require complex cross-context coordination.
|
||||||
|
|
||||||
|
3. **External state library (Redux, Zustand, etc.)** — Rejected. External state libraries are not available in the Headlamp plugin runtime environment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-03-05 | Initial decision accepted |
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# ADR 002: Dual Data Fetching Strategy (Hooks + ApiProxy)
|
||||||
|
|
||||||
|
**Status**: Accepted
|
||||||
|
|
||||||
|
**Date**: 2026-03-05
|
||||||
|
|
||||||
|
**Deciders**: Development Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The plugin needs data from two categories of Kubernetes resources:
|
||||||
|
|
||||||
|
- **Standard resources**: Nodes and Pods, for which Headlamp provides reactive `useList()` hooks via built-in resource classes.
|
||||||
|
- **Custom resources**: GpuDevicePlugin CRD (under `deviceplugin.intel.com/v1`) and DaemonSet pods with specific labels, for which Headlamp does not have built-in support.
|
||||||
|
|
||||||
|
Headlamp provides reactive `useList()` hooks for standard resource classes but does not have built-in support for custom CRDs. The plugin uses three possible label selectors for DaemonSet pod discovery to handle different deployment configurations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Implement a two-track data fetching strategy within the context provider:
|
||||||
|
|
||||||
|
1. **Track 1 (Reactive)**: Use `K8s.ResourceClasses.Node.useList()` and `K8s.ResourceClasses.Pod.useList({namespace:''})` for standard resources. These are reactive to cluster changes and automatically update when resources are created, modified, or deleted.
|
||||||
|
|
||||||
|
2. **Track 2 (Imperative)**: Use `ApiProxy.request()` inside a `useEffect` keyed on `refreshKey` for GpuDevicePlugin CRDs and DaemonSet pods. The `refreshKey` is incremented by the `refresh()` function exposed through the context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- ✅ Leverages Headlamp's reactive hooks for standard resources with automatic updates
|
||||||
|
- ✅ Flexible `ApiProxy` for custom CRDs without needing to register custom resource classes
|
||||||
|
- ✅ Refresh mechanism provides manual control over imperative fetches
|
||||||
|
- ✅ Clean separation of reactive vs imperative data sources
|
||||||
|
- ⚠️ Two different update mechanisms (hooks auto-update vs manual refresh for CRDs)
|
||||||
|
- ⚠️ CRD data may lag behind hook data between refreshes
|
||||||
|
|
||||||
|
The negative consequences are mitigated by providing a manual refresh button in the UI, allowing users to force an update of imperative data when needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
1. **All ApiProxy (no hooks)** — Rejected. Loses reactivity for standard resources, meaning Node and Pod changes would not be reflected until a manual refresh.
|
||||||
|
|
||||||
|
2. **All hooks (register CRD as custom resource class)** — Rejected. Headlamp's `KubeObject` registration is complex for read-only CRD access and would add unnecessary coupling to Headlamp internals.
|
||||||
|
|
||||||
|
3. **Single useEffect for everything** — Rejected. Loses the reactivity benefit for Nodes and Pods, and would require manual refresh for all data instead of just CRDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-03-05 | Initial decision accepted |
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR 003: Graceful CRD Degradation
|
||||||
|
|
||||||
|
**Status**: Accepted
|
||||||
|
|
||||||
|
**Date**: 2026-03-05
|
||||||
|
|
||||||
|
**Deciders**: Development Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The GpuDevicePlugin CRD (`deviceplugin.intel.com/v1`) is only present when the Intel GPU device plugin operator is installed. However, Intel GPUs can be present in a cluster without the operator — the device plugin can be deployed as a plain DaemonSet.
|
||||||
|
|
||||||
|
The plugin should still detect and display GPU resources even without the CRD. GPU nodes are identifiable by node labels (e.g., `intel.feature.node.kubernetes.io/gpu`) and capacity fields (e.g., `gpu.intel.com/i915`). GPU pods are identifiable by resource requests/limits for Intel GPU resources.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Wrap the GpuDevicePlugin CRD fetch in its own `try/catch`. If the fetch fails (CRD not installed), set `crdAvailable` to `false` and continue. GPU nodes and pods are still discovered via node labels, capacity fields, and pod resource requests — independent of the CRD.
|
||||||
|
|
||||||
|
The CRD data enriches the view when available but is not required for core functionality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- ✅ Plugin works on any cluster with Intel GPUs regardless of operator installation
|
||||||
|
- ✅ Progressive enhancement when CRD is available
|
||||||
|
- ✅ No error displayed to the user for a missing CRD
|
||||||
|
- ⚠️ Two code paths (with/without CRD data) increase testing surface
|
||||||
|
- ⚠️ DevicePlugins page is empty without the CRD
|
||||||
|
|
||||||
|
The negative consequences are mitigated by clear messaging on the DevicePlugins page when the CRD is unavailable, informing users that the operator is not installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
1. **Require CRD (hard dependency)** — Rejected. Too restrictive; many clusters run the device plugin as a plain DaemonSet without the operator and its CRD.
|
||||||
|
|
||||||
|
2. **API discovery check before fetch** — Considered, but `try/catch` is simpler and handles all failure modes (CRD not installed, API server errors, permission issues) uniformly.
|
||||||
|
|
||||||
|
3. **Disable plugin entirely without CRD** — Rejected. Core GPU monitoring (node detection, pod resource tracking) works without the CRD and provides significant value on its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-03-05 | Initial decision accepted |
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# ADR 004: Headlamp View Integration via Detail Sections and Column Processors
|
||||||
|
|
||||||
|
**Status**: Accepted
|
||||||
|
|
||||||
|
**Date**: 2026-03-05
|
||||||
|
|
||||||
|
**Deciders**: Development Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The plugin provides its own pages (Overview, Nodes, Pods, etc.) but also needs to enhance Headlamp's native views. Users browsing the standard Nodes list should see GPU information without navigating to the plugin.
|
||||||
|
|
||||||
|
Headlamp offers two integration mechanisms:
|
||||||
|
|
||||||
|
- `registerDetailsViewSection` for injecting sections into resource detail pages.
|
||||||
|
- `registerResourceTableColumnsProcessor` for adding columns to resource list tables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use both integration mechanisms:
|
||||||
|
|
||||||
|
1. **Detail sections**: `registerDetailsViewSection` injects GPU information into Node and Pod detail pages. Resource-kind guards ensure sections only render for the correct resource type.
|
||||||
|
|
||||||
|
2. **Column processors**: `registerResourceTableColumnsProcessor` appends "GPU Type" and "GPU Devices" columns to the native `headlamp-nodes` table.
|
||||||
|
|
||||||
|
Both integration points consume data from the shared `IntelGpuDataProvider` context, so they benefit from the same cached data as the plugin's own pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- ✅ GPU data visible in native Headlamp views without navigation
|
||||||
|
- ✅ Seamless user experience for users already familiar with Headlamp
|
||||||
|
- ✅ Uses Headlamp's official extension APIs for forward compatibility
|
||||||
|
- ✅ Shared context means no duplicate data fetches
|
||||||
|
- ⚠️ Detail sections render for all Nodes/Pods (guard needed to check GPU relevance)
|
||||||
|
- ⚠️ Column processors add columns even when no GPU nodes exist in the cluster
|
||||||
|
|
||||||
|
The negative consequences are mitigated by resource-kind guards and conditional rendering that hide GPU sections when a resource has no GPU relevance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
1. **Plugin pages only (no native view integration)** — Rejected. Users would miss GPU info when browsing standard Headlamp views, reducing discoverability.
|
||||||
|
|
||||||
|
2. **Override native views entirely** — Rejected. Not supported by Headlamp's plugin API and would conflict with other plugins.
|
||||||
|
|
||||||
|
3. **App bar notification only** — Rejected. Insufficient detail for node-level and pod-level GPU information; only suitable for cluster-wide summaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-03-05 | Initial decision accepted |
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
## What is an ADR?
|
||||||
|
|
||||||
|
An Architecture Decision Record (ADR) captures an important architectural decision made along with its context and consequences. ADRs are used to document the reasoning behind significant technical choices so that future contributors can understand why the system is built the way it is.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
This project follows the [Nygard-style ADR format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions):
|
||||||
|
|
||||||
|
- **Title**: Short noun phrase describing the decision
|
||||||
|
- **Status**: Proposed, Accepted, Deprecated, or Superseded
|
||||||
|
- **Date**: When the decision was made
|
||||||
|
- **Deciders**: Who was involved in making the decision
|
||||||
|
- **Context**: What is the issue that motivated the decision
|
||||||
|
- **Decision**: What is the change that was decided
|
||||||
|
- **Consequences**: What becomes easier or more difficult as a result
|
||||||
|
- **Alternatives Considered**: What other options were evaluated
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
| ADR | Title | Status | Date |
|
||||||
|
|-----|-------|--------|------|
|
||||||
|
| [001](001-react-context-state.md) | React Context for Centralized GPU State | Accepted | 2026-03-05 |
|
||||||
|
| [002](002-dual-data-fetching.md) | Dual Data Fetching Strategy (Hooks + ApiProxy) | Accepted | 2026-03-05 |
|
||||||
|
| [003](003-graceful-crd-degradation.md) | Graceful CRD Degradation | Accepted | 2026-03-05 |
|
||||||
|
| [004](004-native-view-integration.md) | Headlamp View Integration via Detail Sections and Column Processors | Accepted | 2026-03-05 |
|
||||||
|
|
||||||
|
## Creating New ADRs
|
||||||
|
|
||||||
|
1. Copy an existing ADR as a template.
|
||||||
|
2. Assign the next sequential number (e.g., `005`).
|
||||||
|
3. Fill in all sections: Status, Date, Deciders, Context, Decision, Consequences, and Alternatives Considered.
|
||||||
|
4. Set the status to `Proposed` until the team reviews and accepts the decision.
|
||||||
|
5. Update this README index table with the new entry.
|
||||||
|
6. Submit as part of a pull request for team review.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Michael Nygard - Documenting Architecture Decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
|
||||||
|
- [ADR GitHub Organization](https://adr.github.io/)
|
||||||
|
- [Headlamp Plugin Development](https://headlamp.dev/docs/latest/development/plugins/)
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
Generated
+869
-529
File diff suppressed because it is too large
Load Diff
+20
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "intel-gpu",
|
"name": "intel-gpu",
|
||||||
"version": "0.4.0",
|
"version": "1.1.0",
|
||||||
"description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring",
|
"description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -24,7 +24,25 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kinvolk/headlamp-plugin": "^0.13.0"
|
"@kinvolk/headlamp-plugin": "^0.13.0",
|
||||||
|
"@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",
|
||||||
|
"lodash": ">=4.18.0",
|
||||||
|
"elliptic": ">=6.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended"]
|
"extends": ["github>privilegedescalation/.github:renovate-config"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||||
|
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IntelGpuDataProvider, useIntelGpuContext } from './IntelGpuDataContext';
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||||
|
K8s: {
|
||||||
|
ResourceClasses: {
|
||||||
|
Node: { useList: vi.fn() },
|
||||||
|
Pod: { useList: vi.fn() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ApiProxy: { request: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Minimal GPU node fixture
|
||||||
|
const gpuNodeRaw = {
|
||||||
|
metadata: {
|
||||||
|
name: 'gpu-node-1',
|
||||||
|
uid: 'uid-001',
|
||||||
|
labels: { 'intel.feature.node.kubernetes.io/gpu': 'true' },
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
capacity: { 'gpu.intel.com/i915': '1' },
|
||||||
|
allocatable: { 'gpu.intel.com/i915': '1' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimal GPU plugin CRD fixture
|
||||||
|
const gpuDevicePluginRaw = {
|
||||||
|
kind: 'GpuDevicePlugin',
|
||||||
|
metadata: { name: 'gpu-plugin-default', uid: 'uid-dp-001' },
|
||||||
|
spec: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeNodeWrapper(raw: unknown) {
|
||||||
|
return { jsonData: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return <IntelGpuDataProvider>{children}</IntelGpuDataProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useIntelGpuContext', () => {
|
||||||
|
it('throws when used outside provider', () => {
|
||||||
|
// Suppress React error boundary output
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(() => renderHook(() => useIntelGpuContext())).toThrow(
|
||||||
|
'useIntelGpuContext must be used within an IntelGpuDataProvider'
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IntelGpuDataProvider', () => {
|
||||||
|
it('renders children', async () => {
|
||||||
|
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
|
||||||
|
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||||
|
vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<IntelGpuDataProvider>
|
||||||
|
<div data-testid="child">hello</div>
|
||||||
|
</IntelGpuDataProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes loading=true while nodes/pods are null', async () => {
|
||||||
|
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([null, null] as any);
|
||||||
|
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([null, null] as any);
|
||||||
|
// Keep async request pending forever
|
||||||
|
vi.mocked(ApiProxy.request).mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes loaded state with GPU nodes once data arrives', async () => {
|
||||||
|
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([
|
||||||
|
[makeNodeWrapper(gpuNodeRaw)] as any,
|
||||||
|
null,
|
||||||
|
] as any);
|
||||||
|
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||||
|
vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.gpuNodes).toHaveLength(1);
|
||||||
|
expect(result.current.gpuNodes[0].metadata.name).toBe('gpu-node-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets crdAvailable=true and populates devicePlugins when ApiProxy returns plugin list', async () => {
|
||||||
|
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
|
||||||
|
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||||
|
|
||||||
|
// First call = CRD list, subsequent calls = plugin pod selectors (empty)
|
||||||
|
vi.mocked(ApiProxy.request)
|
||||||
|
.mockResolvedValueOnce({ items: [gpuDevicePluginRaw] })
|
||||||
|
.mockResolvedValue({ items: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.crdAvailable).toBe(true);
|
||||||
|
expect(result.current.devicePlugins).toHaveLength(1);
|
||||||
|
expect(result.current.devicePlugins[0].metadata.name).toBe('gpu-plugin-default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets crdAvailable=false and does not surface error when ApiProxy throws on CRD request', async () => {
|
||||||
|
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
|
||||||
|
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||||
|
|
||||||
|
// First call (CRD endpoint) throws, plugin pod selectors resolve empty
|
||||||
|
vi.mocked(ApiProxy.request)
|
||||||
|
.mockRejectedValueOnce(new Error('CRD not found'))
|
||||||
|
.mockResolvedValue({ items: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.crdAvailable).toBe(false);
|
||||||
|
expect(result.current.devicePlugins).toHaveLength(0);
|
||||||
|
// Inner CRD error should NOT be bubbled up to the top-level error field
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments refreshKey and re-runs the effect when refresh() is called', async () => {
|
||||||
|
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
|
||||||
|
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
|
||||||
|
vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const callCountBefore = vi.mocked(ApiProxy.request).mock.calls.length;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const callCountAfter = vi.mocked(ApiProxy.request).mock.calls.length;
|
||||||
|
expect(callCountAfter).toBeGreaterThan(callCountBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a hanging CRD request as unavailable after 2s timeout', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const nodeWrapper = { jsonData: {} };
|
||||||
|
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[nodeWrapper], null] as any);
|
||||||
|
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[nodeWrapper], null] as any);
|
||||||
|
vi.mocked(ApiProxy.request)
|
||||||
|
.mockReturnValueOnce(new Promise(() => {}))
|
||||||
|
.mockResolvedValueOnce({ items: [] })
|
||||||
|
.mockResolvedValueOnce({ items: [] })
|
||||||
|
.mockResolvedValueOnce({ items: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await act(async () => {});
|
||||||
|
expect(result.current.crdAvailable).toBe(false);
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,30 @@ export function useIntelGpuContext(): IntelGpuContextValue {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 2_000;
|
||||||
|
|
||||||
|
/** Wraps a promise with a timeout, rejecting if it doesn't settle within ms. */
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Request timed out after ${ms}ms`)), ms)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract raw Kubernetes JSON from Headlamp KubeObject wrappers. */
|
||||||
|
const extractJsonData = (items: unknown[]): unknown[] =>
|
||||||
|
items.map(item =>
|
||||||
|
item && typeof item === 'object' && 'jsonData' in item
|
||||||
|
? (item as { jsonData: unknown }).jsonData
|
||||||
|
: item
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Provider
|
// Provider
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -96,8 +120,11 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
|
|||||||
try {
|
try {
|
||||||
// GpuDevicePlugin CRDs — graceful degradation if CRD not installed
|
// GpuDevicePlugin CRDs — graceful degradation if CRD not installed
|
||||||
try {
|
try {
|
||||||
const pluginList = await ApiProxy.request(
|
const pluginList = await withTimeout(
|
||||||
`/apis/${INTEL_DEVICE_PLUGIN_API_GROUP}/${INTEL_DEVICE_PLUGIN_API_VERSION}/gpudeviceplugins`
|
ApiProxy.request(
|
||||||
|
`/apis/${INTEL_DEVICE_PLUGIN_API_GROUP}/${INTEL_DEVICE_PLUGIN_API_VERSION}/gpudeviceplugins`
|
||||||
|
),
|
||||||
|
DEFAULT_REQUEST_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
if (!cancelled && isKubeList(pluginList)) {
|
if (!cancelled && isKubeList(pluginList)) {
|
||||||
setCrdAvailable(true);
|
setCrdAvailable(true);
|
||||||
@@ -127,10 +154,10 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
|
|||||||
|
|
||||||
for (const url of pluginPodSelectors) {
|
for (const url of pluginPodSelectors) {
|
||||||
try {
|
try {
|
||||||
const list = await ApiProxy.request(url);
|
const list = await withTimeout(ApiProxy.request(url), DEFAULT_REQUEST_TIMEOUT_MS);
|
||||||
if (!cancelled && isKubeList(list)) {
|
if (!cancelled && isKubeList(list)) {
|
||||||
const gpuPluinPods = filterIntelGpuPluginPods(list.items);
|
const gpuPluginPods = filterIntelGpuPluginPods(list.items);
|
||||||
foundPluginPods.push(...gpuPluinPods);
|
foundPluginPods.push(...gpuPluginPods);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore — some selectors may not match
|
// Silently ignore — some selectors may not match
|
||||||
@@ -170,13 +197,6 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
|
|||||||
// type helpers work correctly.
|
// type helpers work correctly.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const extractJsonData = (items: unknown[]): unknown[] =>
|
|
||||||
items.map(item =>
|
|
||||||
item && typeof item === 'object' && 'jsonData' in item
|
|
||||||
? (item as { jsonData: unknown }).jsonData
|
|
||||||
: item
|
|
||||||
);
|
|
||||||
|
|
||||||
const gpuNodes = useMemo(() => {
|
const gpuNodes = useMemo(() => {
|
||||||
if (!allNodes) return [];
|
if (!allNodes) return [];
|
||||||
return filterIntelGpuNodes(extractJsonData(allNodes as unknown[]));
|
return filterIntelGpuNodes(extractJsonData(allNodes as unknown[]));
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* AppBarGpuBadge — compact Intel GPU health indicator in the Headlamp app bar.
|
|
||||||
*
|
|
||||||
* Shows a status chip in the top navigation bar summarising GPU plugin health.
|
|
||||||
* Hides itself when no Intel GPU plugin is detected.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
|
||||||
import React from 'react';
|
|
||||||
import { useIntelGpuContext } from '../api/IntelGpuDataContext';
|
|
||||||
|
|
||||||
export default function AppBarGpuBadge() {
|
|
||||||
const { pluginInstalled, gpuNodes, devicePlugins, loading } = useIntelGpuContext();
|
|
||||||
|
|
||||||
// Hide when loading or no plugin present
|
|
||||||
if (loading || !pluginInstalled) return null;
|
|
||||||
|
|
||||||
const hasUnhealthyPlugin = devicePlugins.some(p => {
|
|
||||||
const desired = p.status?.desiredNumberScheduled ?? 0;
|
|
||||||
const ready = p.status?.numberReady ?? 0;
|
|
||||||
const unavailable = p.status?.numberUnavailable ?? 0;
|
|
||||||
return (desired > 0 && ready < desired) || unavailable > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = hasUnhealthyPlugin ? 'warning' : 'success';
|
|
||||||
const nodeCount = gpuNodes.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
padding: '0 8px',
|
|
||||||
cursor: 'default',
|
|
||||||
}}
|
|
||||||
title={`Intel GPU: ${nodeCount} node${nodeCount !== 1 ? 's' : ''}`}
|
|
||||||
>
|
|
||||||
<StatusLabel status={status}>
|
|
||||||
<span style={{ fontSize: '11px', fontWeight: 600 }}>
|
|
||||||
Intel GPU{nodeCount > 0 ? ` · ${nodeCount}N` : ''}
|
|
||||||
</span>
|
|
||||||
</StatusLabel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { IntelGpuContextValue, useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import { fetchGpuMetrics, GpuChipMetrics, GpuMetrics } from '../api/metrics';
|
||||||
|
import MetricsPage from './MetricsPage';
|
||||||
|
|
||||||
|
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||||
|
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||||
|
SectionBox: ({ title, children }: { title: string; children?: React.ReactNode }) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||||
|
NameValueTable: ({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: Array<{ name: React.ReactNode; value: React.ReactNode }>;
|
||||||
|
}) => (
|
||||||
|
<dl>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<dt>{r.name}</dt>
|
||||||
|
<dd>{r.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
SimpleTable: ({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
columns: Array<{ label: string; getter: (item: unknown) => React.ReactNode }>;
|
||||||
|
data: unknown[];
|
||||||
|
}) => (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(c => (
|
||||||
|
<th key={c.label}>{c.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map(c => (
|
||||||
|
<td key={c.label}>{c.getter(item)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||||
|
<span data-status={status}>{children}</span>
|
||||||
|
),
|
||||||
|
PercentageBar: () => <div data-testid="percentage-bar" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/IntelGpuDataContext', () => ({
|
||||||
|
useIntelGpuContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/metrics', () => ({
|
||||||
|
fetchGpuMetrics: vi.fn(),
|
||||||
|
formatWatts: (w: number) => `${w.toFixed(1)} W`,
|
||||||
|
formatPercent: (used: number, max: number) =>
|
||||||
|
max <= 0 ? '—' : `${Math.round((used / max) * 100)}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeContext(overrides: Partial<IntelGpuContextValue> = {}): IntelGpuContextValue {
|
||||||
|
return {
|
||||||
|
devicePlugins: [],
|
||||||
|
pluginInstalled: false,
|
||||||
|
gpuNodes: [],
|
||||||
|
gpuPods: [],
|
||||||
|
pluginPods: [],
|
||||||
|
crdAvailable: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMetrics(chips: GpuChipMetrics[]): GpuMetrics {
|
||||||
|
return {
|
||||||
|
chips,
|
||||||
|
fetchedAt: new Date('2025-03-21T10:00:00Z').toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleChip: GpuChipMetrics = {
|
||||||
|
nodeName: 'gpu-node-1',
|
||||||
|
chip: '0000:09:01_0',
|
||||||
|
instance: '192.168.1.10:9100',
|
||||||
|
powerWatts: 45.3,
|
||||||
|
powerMaxWatts: 120.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MetricsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loader when ctxLoading=true but heading is visible immediately', () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true }));
|
||||||
|
// fetchGpuMetrics should never be called in loading state
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(null);
|
||||||
|
render(<MetricsPage />);
|
||||||
|
// Heading renders immediately, loader appears below it while waiting for context
|
||||||
|
expect(screen.getByText('Intel GPU — Metrics')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Intel GPU data...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Prometheus Unreachable" section when fetchGpuMetrics returns null', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(null);
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Prometheus Unreachable')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No i915 Metrics in Prometheus" when fetchGpuMetrics returns empty chips', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('No i915 Metrics in Prometheus')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows chip cards with node name when fetchGpuMetrics returns chips', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([sampleChip]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// GpuChipCard title format: "{nodeName} — {chip}"
|
||||||
|
expect(screen.getByText('gpu-node-1 — 0000:09:01_0')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always renders MetricRequirements section', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
// The MetricRequirements section box is titled "Metric Availability"
|
||||||
|
expect(screen.getByText('Metric Availability')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows GPU Power Summary section when chips are present', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([sampleChip]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('GPU Power Summary')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-triggers fetch when refresh button is clicked', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
// Wait for initial fetch to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(vi.mocked(fetchGpuMetrics)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const callsBefore = vi.mocked(fetchGpuMetrics).mock.calls.length;
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /refresh metrics/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(vi.mocked(fetchGpuMetrics).mock.calls.length).toBeGreaterThan(callsBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Intel GPU — Metrics" heading', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Intel GPU — Metrics')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows power values for chip cards', async () => {
|
||||||
|
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: false }));
|
||||||
|
vi.mocked(fetchGpuMetrics).mockResolvedValue(makeMetrics([sampleChip]));
|
||||||
|
|
||||||
|
render(<MetricsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// formatWatts mock: "45.3 W" and "120.0 W"
|
||||||
|
expect(screen.getAllByText(/45\.3 W/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,34 +194,41 @@ export default function MetricsPage() {
|
|||||||
const [metrics, setMetrics] = useState<GpuMetrics | null>(null);
|
const [metrics, setMetrics] = useState<GpuMetrics | null>(null);
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [fetchSeq, setFetchSeq] = useState(0);
|
||||||
|
|
||||||
const doFetch = useCallback(async () => {
|
const doFetch = useCallback(() => {
|
||||||
setFetching(true);
|
setFetchSeq(s => s + 1);
|
||||||
setFetchError(null);
|
|
||||||
try {
|
|
||||||
const result = await fetchGpuMetrics();
|
|
||||||
setMetrics(result);
|
|
||||||
if (!result) {
|
|
||||||
setFetchError(
|
|
||||||
'Could not reach Prometheus. Ensure kube-prometheus-stack is installed in the monitoring namespace.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setFetchError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setFetching(false);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ctxLoading) {
|
if (ctxLoading) return;
|
||||||
void doFetch();
|
|
||||||
}
|
|
||||||
}, [ctxLoading, doFetch]);
|
|
||||||
|
|
||||||
if (ctxLoading) {
|
let cancelled = false;
|
||||||
return <Loader title="Loading Intel GPU data..." />;
|
setFetching(true);
|
||||||
}
|
setFetchError(null);
|
||||||
|
|
||||||
|
fetchGpuMetrics()
|
||||||
|
.then(result => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setMetrics(result);
|
||||||
|
if (!result) {
|
||||||
|
setFetchError(
|
||||||
|
'Could not reach Prometheus. Ensure kube-prometheus-stack is installed in the monitoring namespace.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setFetchError(e instanceof Error ? e.message : String(e));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setFetching(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [ctxLoading, fetchSeq]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -236,7 +243,7 @@ export default function MetricsPage() {
|
|||||||
<SectionHeader title="Intel GPU — Metrics" />
|
<SectionHeader title="Intel GPU — Metrics" />
|
||||||
<button
|
<button
|
||||||
onClick={() => void doFetch()}
|
onClick={() => void doFetch()}
|
||||||
disabled={fetching}
|
disabled={fetching || ctxLoading}
|
||||||
aria-label="Refresh metrics"
|
aria-label="Refresh metrics"
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 16px',
|
padding: '6px 16px',
|
||||||
@@ -244,15 +251,18 @@ export default function MetricsPage() {
|
|||||||
color: 'var(--mui-palette-primary-main, #0071c5)',
|
color: 'var(--mui-palette-primary-main, #0071c5)',
|
||||||
border: '1px solid var(--mui-palette-primary-main, #0071c5)',
|
border: '1px solid var(--mui-palette-primary-main, #0071c5)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: fetching || ctxLoading ? 'not-allowed' : 'pointer',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
opacity: fetching || ctxLoading ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{fetching ? 'Refreshing…' : 'Refresh'}
|
{fetching ? 'Refreshing…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{ctxLoading && <Loader title="Loading Intel GPU data..." />}
|
||||||
|
|
||||||
<MetricRequirements />
|
<MetricRequirements />
|
||||||
|
|
||||||
{fetching && !metrics && <Loader title="Querying Prometheus for GPU metrics..." />}
|
{fetching && !metrics && <Loader title="Querying Prometheus for GPU metrics..." />}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,11 +52,11 @@ export default function NodeDetailSection({ resource }: NodeDetailSectionProps)
|
|||||||
metadata: { name: string; labels?: Record<string, string> };
|
metadata: { name: string; labels?: Record<string, string> };
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeName = (node as { metadata: { name: string } }).metadata.name;
|
const nodeName = node.metadata.name;
|
||||||
const capacity = getGpuResources((node as any).status?.capacity);
|
const capacity = getGpuResources(node.status?.capacity);
|
||||||
const allocatable = getGpuResources((node as any).status?.allocatable);
|
const allocatable = getGpuResources(node.status?.allocatable);
|
||||||
|
|
||||||
const gpuType = getNodeGpuType(node as any);
|
const gpuType = getNodeGpuType(node);
|
||||||
|
|
||||||
// Find GPU pods scheduled on this node
|
// Find GPU pods scheduled on this node
|
||||||
const podsOnNode = loading ? [] : gpuPods.filter(p => p.spec?.nodeName === nodeName);
|
const podsOnNode = loading ? [] : gpuPods.filter(p => p.spec?.nodeName === nodeName);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -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
|
* Registers sidebar entries, routes, detail view sections, and table column
|
||||||
* processors for Intel GPU device plugin visibility in Headlamp.
|
* processors for Intel GPU device plugin visibility in Headlamp.
|
||||||
|
|||||||
@@ -6,5 +6,8 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
exclude: ['e2e/**', 'node_modules/**'],
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user