feat: initial release of headlamp-intel-gpu-plugin v0.1.0
Adds a Headlamp plugin for Intel GPU device plugin visibility: - Dedicated sidebar section: Overview, Device Plugins, GPU Nodes, GPU Pods - Native Node detail page injection: GPU capacity, allocatable, utilization, active pods - Native Pod detail page injection: per-container GPU resource requests/limits - Native Nodes table: GPU Type and GPU Devices columns - App bar health badge (hidden when plugin not installed) - GpuDevicePlugin CRD monitoring (deviceplugin.intel.com/v1) with graceful degradation when CRD is not present - Supports discrete (i915), Xe, and integrated GPU nodes via node labels - 48 unit tests, TypeScript clean, 28 kB production bundle Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tar.gz
|
||||||
|
.playwright-mcp/
|
||||||
Generated
+18188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "headlamp-intel-gpu-plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/cpfarhood/headlamp-intel-gpu-plugin.git"
|
||||||
|
},
|
||||||
|
"author": "cpfarhood",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "headlamp-plugin start",
|
||||||
|
"build": "headlamp-plugin build",
|
||||||
|
"package": "headlamp-plugin package",
|
||||||
|
"tsc": "tsc --noEmit",
|
||||||
|
"lint": "eslint --ext .ts,.tsx src/",
|
||||||
|
"lint:fix": "eslint --ext .ts,.tsx --fix src/",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"format:check": "prettier --check src/",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@kinvolk/headlamp-plugin": "^0.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* IntelGpuDataContext — shared data provider for Intel GPU device plugin resources.
|
||||||
|
*
|
||||||
|
* Wraps K8s hook calls and ApiProxy requests, providing filtered Intel GPU
|
||||||
|
* resources to all child pages through React context, avoiding prop drilling
|
||||||
|
* and duplicate API calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
filterGpuRequestingPods,
|
||||||
|
filterIntelGpuNodes,
|
||||||
|
filterIntelGpuPluginPods,
|
||||||
|
GpuDevicePlugin,
|
||||||
|
INTEL_DEVICE_PLUGIN_API_GROUP,
|
||||||
|
INTEL_DEVICE_PLUGIN_API_VERSION,
|
||||||
|
IntelGpuNode,
|
||||||
|
IntelGpuPod,
|
||||||
|
isGpuDevicePlugin,
|
||||||
|
isKubeList,
|
||||||
|
} from './k8s';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context shape
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface IntelGpuContextValue {
|
||||||
|
/** GpuDevicePlugin CRD instances — one per GPU type/config */
|
||||||
|
devicePlugins: GpuDevicePlugin[];
|
||||||
|
/** True if at least one GpuDevicePlugin CR exists */
|
||||||
|
pluginInstalled: boolean;
|
||||||
|
|
||||||
|
/** Nodes that have Intel GPU resources or labels */
|
||||||
|
gpuNodes: IntelGpuNode[];
|
||||||
|
|
||||||
|
/** Pods requesting Intel GPU resources */
|
||||||
|
gpuPods: IntelGpuPod[];
|
||||||
|
|
||||||
|
/** Intel GPU device plugin daemon pods */
|
||||||
|
pluginPods: IntelGpuPod[];
|
||||||
|
|
||||||
|
/** True if the GpuDevicePlugin CRD is available on the cluster */
|
||||||
|
crdAvailable: boolean;
|
||||||
|
|
||||||
|
/** Loading / error state */
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
/** Manual refresh trigger */
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const IntelGpuContext = createContext<IntelGpuContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useIntelGpuContext(): IntelGpuContextValue {
|
||||||
|
const ctx = useContext(IntelGpuContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useIntelGpuContext must be used within an IntelGpuDataProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function IntelGpuDataProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
// K8s resource hooks — headlamp re-fetches on cluster context changes
|
||||||
|
const [allNodes, nodeError] = K8s.ResourceClasses.Node.useList();
|
||||||
|
const [allPods, podError] = K8s.ResourceClasses.Pod.useList({ namespace: '' });
|
||||||
|
|
||||||
|
// Async state for CRD resources
|
||||||
|
const [devicePlugins, setDevicePlugins] = useState<GpuDevicePlugin[]>([]);
|
||||||
|
const [pluginPods, setPluginPods] = useState<IntelGpuPod[]>([]);
|
||||||
|
const [crdAvailable, setCrdAvailable] = useState(false);
|
||||||
|
const [asyncLoading, setAsyncLoading] = useState(true);
|
||||||
|
const [asyncError, setAsyncError] = useState<string | null>(null);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setRefreshKey(k => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function fetchAsync() {
|
||||||
|
setAsyncLoading(true);
|
||||||
|
setAsyncError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// GpuDevicePlugin CRDs — graceful degradation if CRD not installed
|
||||||
|
try {
|
||||||
|
const pluginList = await ApiProxy.request(
|
||||||
|
`/apis/${INTEL_DEVICE_PLUGIN_API_GROUP}/${INTEL_DEVICE_PLUGIN_API_VERSION}/gpudeviceplugins`
|
||||||
|
);
|
||||||
|
if (!cancelled && isKubeList(pluginList)) {
|
||||||
|
setCrdAvailable(true);
|
||||||
|
setDevicePlugins(pluginList.items.filter(isGpuDevicePlugin));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCrdAvailable(false);
|
||||||
|
setDevicePlugins([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intel GPU plugin DaemonSet pods — look across all namespaces
|
||||||
|
// The device plugin is commonly deployed in kube-system but may vary
|
||||||
|
const pluginPodSelectors = [
|
||||||
|
// Intel device plugins operator deployment
|
||||||
|
`/api/v1/pods?labelSelector=${encodeURIComponent('app=intel-gpu-plugin')}`,
|
||||||
|
// Alternative: by component label
|
||||||
|
`/api/v1/pods?labelSelector=${encodeURIComponent('app.kubernetes.io/name=intel-gpu-plugin')}`,
|
||||||
|
// Intel device plugins from inteldeviceplugins-system namespace
|
||||||
|
`/api/v1/namespaces/inteldeviceplugins-system/pods`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const foundPluginPods: IntelGpuPod[] = [];
|
||||||
|
|
||||||
|
for (const url of pluginPodSelectors) {
|
||||||
|
try {
|
||||||
|
const list = await ApiProxy.request(url);
|
||||||
|
if (!cancelled && isKubeList(list)) {
|
||||||
|
const gpuPluinPods = filterIntelGpuPluginPods(list.items);
|
||||||
|
foundPluginPods.push(...gpuPluinPods);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — some selectors may not match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by pod UID
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const uniquePluginPods = foundPluginPods.filter(p => {
|
||||||
|
const uid = p.metadata.uid;
|
||||||
|
if (!uid || seen.has(uid)) return false;
|
||||||
|
seen.add(uid);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelled) setPluginPods(uniquePluginPods);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAsyncError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setAsyncLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchAsync();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [refreshKey]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Derived / filtered values — memoized to avoid recomputation on every render
|
||||||
|
//
|
||||||
|
// Headlamp useList() returns KubeObject class instances that store raw
|
||||||
|
// Kubernetes JSON under `.jsonData`. Extract jsonData so our plain-object
|
||||||
|
// 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(() => {
|
||||||
|
if (!allNodes) return [];
|
||||||
|
return filterIntelGpuNodes(extractJsonData(allNodes as unknown[]));
|
||||||
|
}, [allNodes]);
|
||||||
|
|
||||||
|
const gpuPods = useMemo(() => {
|
||||||
|
if (!allPods) return [];
|
||||||
|
return filterGpuRequestingPods(extractJsonData(allPods as unknown[]));
|
||||||
|
}, [allPods]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Combined loading / error state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const loading = asyncLoading || !allNodes || !allPods;
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (nodeError) errors.push(String(nodeError));
|
||||||
|
if (podError) errors.push(String(podError));
|
||||||
|
if (asyncError) errors.push(asyncError);
|
||||||
|
const error = errors.length > 0 ? errors.join('; ') : null;
|
||||||
|
|
||||||
|
const pluginInstalled = devicePlugins.length > 0 || pluginPods.length > 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Memoized context value
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const value = useMemo<IntelGpuContextValue>(
|
||||||
|
() => ({
|
||||||
|
devicePlugins,
|
||||||
|
pluginInstalled,
|
||||||
|
gpuNodes,
|
||||||
|
gpuPods,
|
||||||
|
pluginPods,
|
||||||
|
crdAvailable,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
devicePlugins,
|
||||||
|
pluginInstalled,
|
||||||
|
gpuNodes,
|
||||||
|
gpuPods,
|
||||||
|
pluginPods,
|
||||||
|
crdAvailable,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <IntelGpuContext.Provider value={value}>{children}</IntelGpuContext.Provider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for Intel GPU k8s helper functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
filterGpuRequestingPods,
|
||||||
|
filterIntelGpuNodes,
|
||||||
|
formatAge,
|
||||||
|
formatGpuResourceName,
|
||||||
|
formatGpuType,
|
||||||
|
getNodeGpuCount,
|
||||||
|
getNodeGpuType,
|
||||||
|
getPodGpuRequests,
|
||||||
|
INTEL_GPU_NODE_LABEL,
|
||||||
|
INTEL_GPU_RESOURCE,
|
||||||
|
INTEL_GPU_XE_RESOURCE,
|
||||||
|
isGpuRequestingPod,
|
||||||
|
isIntelGpuNode,
|
||||||
|
isKubeList,
|
||||||
|
isNodeReady,
|
||||||
|
pluginStatusText,
|
||||||
|
pluginStatusToStatus,
|
||||||
|
type GpuDevicePlugin,
|
||||||
|
type IntelGpuNode,
|
||||||
|
type IntelGpuPod,
|
||||||
|
} from './k8s';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeNode(overrides: Record<string, unknown> = {}): IntelGpuNode {
|
||||||
|
return {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Node',
|
||||||
|
metadata: { name: 'test-node' },
|
||||||
|
status: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGpuNode(type: 'discrete' | 'integrated' | 'generic' = 'discrete'): IntelGpuNode {
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
if (type === 'discrete') labels['node-role.kubernetes.io/gpu'] = 'true';
|
||||||
|
if (type === 'integrated') labels['node-role.kubernetes.io/igpu'] = 'true';
|
||||||
|
if (type === 'generic') labels[INTEL_GPU_NODE_LABEL] = 'true';
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Node',
|
||||||
|
metadata: { name: 'gpu-node', labels },
|
||||||
|
status: {
|
||||||
|
capacity: { [INTEL_GPU_RESOURCE]: '2' },
|
||||||
|
allocatable: { [INTEL_GPU_RESOURCE]: '2' },
|
||||||
|
conditions: [{ type: 'Ready', status: 'True' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGpuPod(gpuResourceKey: string = INTEL_GPU_RESOURCE, amount = '1'): IntelGpuPod {
|
||||||
|
return {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Pod',
|
||||||
|
metadata: { name: 'gpu-pod', namespace: 'default' },
|
||||||
|
spec: {
|
||||||
|
nodeName: 'gpu-node',
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'workload',
|
||||||
|
resources: {
|
||||||
|
requests: { [gpuResourceKey]: amount },
|
||||||
|
limits: { [gpuResourceKey]: amount },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
status: { phase: 'Running' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isIntelGpuNode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('isIntelGpuNode', () => {
|
||||||
|
it('returns true for nodes with discrete GPU label', () => {
|
||||||
|
const node = makeGpuNode('discrete');
|
||||||
|
expect(isIntelGpuNode(node)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for nodes with integrated GPU label', () => {
|
||||||
|
const node = makeGpuNode('integrated');
|
||||||
|
expect(isIntelGpuNode(node)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for nodes with generic Intel GPU label', () => {
|
||||||
|
const node = makeGpuNode('generic');
|
||||||
|
expect(isIntelGpuNode(node)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for nodes with gpu.intel.com/* in capacity', () => {
|
||||||
|
const node = makeNode({
|
||||||
|
status: { capacity: { 'gpu.intel.com/i915': '1' } },
|
||||||
|
});
|
||||||
|
expect(isIntelGpuNode(node)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for nodes with no GPU labels or resources', () => {
|
||||||
|
const node = makeNode({
|
||||||
|
metadata: { name: 'regular-node', labels: {} },
|
||||||
|
status: { capacity: { cpu: '8', memory: '16Gi' } },
|
||||||
|
});
|
||||||
|
expect(isIntelGpuNode(node)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for null/undefined', () => {
|
||||||
|
expect(isIntelGpuNode(null)).toBe(false);
|
||||||
|
expect(isIntelGpuNode(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// filterIntelGpuNodes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('filterIntelGpuNodes', () => {
|
||||||
|
it('filters out non-GPU nodes', () => {
|
||||||
|
const gpuNode = makeGpuNode('discrete');
|
||||||
|
const regularNode = makeNode({ metadata: { name: 'regular' } });
|
||||||
|
const result = filterIntelGpuNodes([gpuNode, regularNode]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].metadata.name).toBe('gpu-node');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty array', () => {
|
||||||
|
expect(filterIntelGpuNodes([])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getNodeGpuType
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('getNodeGpuType', () => {
|
||||||
|
it('returns discrete for GPU node role label', () => {
|
||||||
|
expect(getNodeGpuType(makeGpuNode('discrete'))).toBe('discrete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns integrated for iGPU node role label', () => {
|
||||||
|
expect(getNodeGpuType(makeGpuNode('integrated'))).toBe('integrated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown for generic Intel GPU label', () => {
|
||||||
|
expect(getNodeGpuType(makeGpuNode('generic'))).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown for nodes with no labels', () => {
|
||||||
|
const node = makeNode({ status: { capacity: { [INTEL_GPU_RESOURCE]: '1' } } });
|
||||||
|
expect(getNodeGpuType(node)).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getNodeGpuCount
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('getNodeGpuCount', () => {
|
||||||
|
it('returns count from i915 resource', () => {
|
||||||
|
const node = makeNode({
|
||||||
|
status: { capacity: { [INTEL_GPU_RESOURCE]: '4' } },
|
||||||
|
});
|
||||||
|
expect(getNodeGpuCount(node)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns count from xe resource', () => {
|
||||||
|
const node = makeNode({
|
||||||
|
status: { capacity: { [INTEL_GPU_XE_RESOURCE]: '2' } },
|
||||||
|
});
|
||||||
|
expect(getNodeGpuCount(node)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns sum of i915 and xe resources', () => {
|
||||||
|
const node = makeNode({
|
||||||
|
status: {
|
||||||
|
capacity: {
|
||||||
|
[INTEL_GPU_RESOURCE]: '2',
|
||||||
|
[INTEL_GPU_XE_RESOURCE]: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getNodeGpuCount(node)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for nodes with no GPU capacity', () => {
|
||||||
|
const node = makeNode({ status: { capacity: { cpu: '8' } } });
|
||||||
|
expect(getNodeGpuCount(node)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isNodeReady
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('isNodeReady', () => {
|
||||||
|
it('returns true when Ready condition is True', () => {
|
||||||
|
const node = makeNode({
|
||||||
|
status: { conditions: [{ type: 'Ready', status: 'True' }] },
|
||||||
|
});
|
||||||
|
expect(isNodeReady(node)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when Ready condition is False', () => {
|
||||||
|
const node = makeNode({
|
||||||
|
status: { conditions: [{ type: 'Ready', status: 'False' }] },
|
||||||
|
});
|
||||||
|
expect(isNodeReady(node)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when no conditions', () => {
|
||||||
|
const node = makeNode({ status: {} });
|
||||||
|
expect(isNodeReady(node)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isGpuRequestingPod
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('isGpuRequestingPod', () => {
|
||||||
|
it('returns true for pods requesting i915 GPU', () => {
|
||||||
|
expect(isGpuRequestingPod(makeGpuPod(INTEL_GPU_RESOURCE))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for pods requesting xe GPU', () => {
|
||||||
|
expect(isGpuRequestingPod(makeGpuPod(INTEL_GPU_XE_RESOURCE))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for pods requesting millicores', () => {
|
||||||
|
expect(isGpuRequestingPod(makeGpuPod('gpu.intel.com/millicores', '500'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for pods with no GPU resources', () => {
|
||||||
|
const pod: IntelGpuPod = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Pod',
|
||||||
|
metadata: { name: 'no-gpu-pod' },
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'app',
|
||||||
|
resources: {
|
||||||
|
requests: { cpu: '1', memory: '1Gi' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(isGpuRequestingPod(pod)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for null', () => {
|
||||||
|
expect(isGpuRequestingPod(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// filterGpuRequestingPods
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('filterGpuRequestingPods', () => {
|
||||||
|
it('filters out non-GPU pods', () => {
|
||||||
|
const gpuPod = makeGpuPod();
|
||||||
|
const regularPod: IntelGpuPod = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Pod',
|
||||||
|
metadata: { name: 'regular' },
|
||||||
|
spec: { containers: [{ name: 'app' }] },
|
||||||
|
};
|
||||||
|
const result = filterGpuRequestingPods([gpuPod, regularPod]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].metadata.name).toBe('gpu-pod');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getPodGpuRequests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('getPodGpuRequests', () => {
|
||||||
|
it('returns GPU resource requests from containers', () => {
|
||||||
|
const pod = makeGpuPod(INTEL_GPU_RESOURCE, '2');
|
||||||
|
const requests = getPodGpuRequests(pod);
|
||||||
|
expect(requests[INTEL_GPU_RESOURCE]).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object for non-GPU pods', () => {
|
||||||
|
const pod: IntelGpuPod = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Pod',
|
||||||
|
metadata: { name: 'regular' },
|
||||||
|
spec: { containers: [{ name: 'app', resources: { requests: { cpu: '1' } } }] },
|
||||||
|
};
|
||||||
|
expect(getPodGpuRequests(pod)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums requests across multiple containers', () => {
|
||||||
|
const pod: IntelGpuPod = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Pod',
|
||||||
|
metadata: { name: 'multi' },
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{ name: 'a', resources: { requests: { [INTEL_GPU_RESOURCE]: '1' } } },
|
||||||
|
{ name: 'b', resources: { requests: { [INTEL_GPU_RESOURCE]: '2' } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const requests = getPodGpuRequests(pod);
|
||||||
|
expect(requests[INTEL_GPU_RESOURCE]).toBe('3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isKubeList
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('isKubeList', () => {
|
||||||
|
it('returns true for objects with items array', () => {
|
||||||
|
expect(isKubeList({ items: [] })).toBe(true);
|
||||||
|
expect(isKubeList({ items: [1, 2, 3] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for objects without items', () => {
|
||||||
|
expect(isKubeList({ data: [] })).toBe(false);
|
||||||
|
expect(isKubeList(null)).toBe(false);
|
||||||
|
expect(isKubeList('string')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// formatAge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('formatAge', () => {
|
||||||
|
it('returns unknown for undefined', () => {
|
||||||
|
expect(formatAge(undefined)).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats seconds', () => {
|
||||||
|
const ts = new Date(Date.now() - 30 * 1000).toISOString();
|
||||||
|
expect(formatAge(ts)).toBe('30s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats minutes', () => {
|
||||||
|
const ts = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||||
|
expect(formatAge(ts)).toBe('5m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats hours', () => {
|
||||||
|
const ts = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
||||||
|
expect(formatAge(ts)).toBe('3h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats days', () => {
|
||||||
|
const ts = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
expect(formatAge(ts)).toBe('2d');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// formatGpuResourceName
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('formatGpuResourceName', () => {
|
||||||
|
it('formats i915 resource', () => {
|
||||||
|
expect(formatGpuResourceName('gpu.intel.com/i915')).toBe('GPU (i915)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats xe resource', () => {
|
||||||
|
expect(formatGpuResourceName('gpu.intel.com/xe')).toBe('GPU (Xe)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats millicores resource', () => {
|
||||||
|
expect(formatGpuResourceName('gpu.intel.com/millicores')).toBe('GPU Millicores');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns raw suffix for unknown resources', () => {
|
||||||
|
expect(formatGpuResourceName('gpu.intel.com/custom')).toBe('custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// formatGpuType
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('formatGpuType', () => {
|
||||||
|
it('formats discrete', () => {
|
||||||
|
expect(formatGpuType('discrete')).toBe('Discrete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats integrated', () => {
|
||||||
|
expect(formatGpuType('integrated')).toBe('Integrated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats unknown', () => {
|
||||||
|
expect(formatGpuType('unknown')).toBe('Unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// pluginStatusToStatus
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('pluginStatusToStatus', () => {
|
||||||
|
function makePlugin(
|
||||||
|
desired: number,
|
||||||
|
ready: number,
|
||||||
|
unavailable = 0
|
||||||
|
): GpuDevicePlugin {
|
||||||
|
return {
|
||||||
|
apiVersion: 'deviceplugin.intel.com/v1',
|
||||||
|
kind: 'GpuDevicePlugin',
|
||||||
|
metadata: { name: 'test-plugin' },
|
||||||
|
spec: {},
|
||||||
|
status: {
|
||||||
|
desiredNumberScheduled: desired,
|
||||||
|
numberReady: ready,
|
||||||
|
numberUnavailable: unavailable,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns success when all nodes ready', () => {
|
||||||
|
expect(pluginStatusToStatus(makePlugin(3, 3))).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns warning when desired is 0', () => {
|
||||||
|
expect(pluginStatusToStatus(makePlugin(0, 0))).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns warning when some nodes unavailable', () => {
|
||||||
|
expect(pluginStatusToStatus(makePlugin(3, 2, 1))).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when ready < desired with no unavailable', () => {
|
||||||
|
expect(pluginStatusToStatus(makePlugin(3, 1))).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// pluginStatusText
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('pluginStatusText', () => {
|
||||||
|
it('shows ready/desired counts', () => {
|
||||||
|
const plugin: GpuDevicePlugin = {
|
||||||
|
apiVersion: 'deviceplugin.intel.com/v1',
|
||||||
|
kind: 'GpuDevicePlugin',
|
||||||
|
metadata: { name: 'p' },
|
||||||
|
spec: {},
|
||||||
|
status: { desiredNumberScheduled: 3, numberReady: 2 },
|
||||||
|
};
|
||||||
|
expect(pluginStatusText(plugin)).toBe('2/3 ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no nodes scheduled when desired is 0', () => {
|
||||||
|
const plugin: GpuDevicePlugin = {
|
||||||
|
apiVersion: 'deviceplugin.intel.com/v1',
|
||||||
|
kind: 'GpuDevicePlugin',
|
||||||
|
metadata: { name: 'p' },
|
||||||
|
spec: {},
|
||||||
|
status: { desiredNumberScheduled: 0, numberReady: 0 },
|
||||||
|
};
|
||||||
|
expect(pluginStatusText(plugin)).toBe('No nodes scheduled');
|
||||||
|
});
|
||||||
|
});
|
||||||
+393
@@ -0,0 +1,393 @@
|
|||||||
|
/**
|
||||||
|
* Kubernetes type definitions and helper functions for Intel GPU device plugin resources.
|
||||||
|
*
|
||||||
|
* All K8s resource types are typed at the fields we actually use.
|
||||||
|
* External data from the API is validated at the boundary before use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Intel GPU device plugin constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** API group for Intel device plugin CRDs */
|
||||||
|
export const INTEL_DEVICE_PLUGIN_API_GROUP = 'deviceplugin.intel.com';
|
||||||
|
export const INTEL_DEVICE_PLUGIN_API_VERSION = 'v1';
|
||||||
|
|
||||||
|
/** Kubernetes extended resource names for Intel GPU */
|
||||||
|
export const INTEL_GPU_RESOURCE = 'gpu.intel.com/i915' as const;
|
||||||
|
export const INTEL_GPU_XE_RESOURCE = 'gpu.intel.com/xe' as const;
|
||||||
|
export const INTEL_GPU_MILLICORES_RESOURCE = 'gpu.intel.com/millicores' as const;
|
||||||
|
export const INTEL_GPU_MEMORY_RESOURCE = 'gpu.intel.com/memory.max' as const;
|
||||||
|
|
||||||
|
/** All Intel GPU resource names (prefix match) */
|
||||||
|
export const INTEL_GPU_RESOURCE_PREFIX = 'gpu.intel.com/';
|
||||||
|
|
||||||
|
/** Node labels set by Intel Node Feature Discovery */
|
||||||
|
export const INTEL_GPU_NODE_LABEL = 'intel.feature.node.kubernetes.io/gpu';
|
||||||
|
export const INTEL_DISCRETE_GPU_NODE_ROLE = 'node-role.kubernetes.io/gpu';
|
||||||
|
export const INTEL_INTEGRATED_GPU_NODE_ROLE = 'node-role.kubernetes.io/igpu';
|
||||||
|
|
||||||
|
/** Label selector for Intel GPU device plugin DaemonSet pods */
|
||||||
|
export const INTEL_GPU_PLUGIN_LABEL_SELECTOR =
|
||||||
|
'app=intel-gpu-plugin';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic Kubernetes object base shapes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface KubeObjectMeta {
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
creationTimestamp?: string;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
|
uid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KubeObject {
|
||||||
|
apiVersion?: string;
|
||||||
|
kind?: string;
|
||||||
|
metadata: KubeObjectMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GpuDevicePlugin CRD (deviceplugin.intel.com/v1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GpuDevicePluginSpec {
|
||||||
|
image?: string;
|
||||||
|
sharedDevNum?: number;
|
||||||
|
enableMonitoring?: boolean;
|
||||||
|
preferredAllocationPolicy?: string;
|
||||||
|
nodeSelector?: Record<string, string>;
|
||||||
|
resourceManager?: boolean;
|
||||||
|
logLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GpuDevicePluginStatus {
|
||||||
|
/** Number of nodes where the plugin daemonset is scheduled */
|
||||||
|
desiredNumberScheduled?: number;
|
||||||
|
/** Number of nodes where the plugin daemonset is running and ready */
|
||||||
|
numberReady?: number;
|
||||||
|
/** Number of nodes where the plugin daemonset pod is unavailable */
|
||||||
|
numberUnavailable?: number;
|
||||||
|
/** Number of nodes where the plugin daemonset is available */
|
||||||
|
numberAvailable?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GpuDevicePlugin extends KubeObject {
|
||||||
|
spec: GpuDevicePluginSpec;
|
||||||
|
status?: GpuDevicePluginStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGpuDevicePlugin(value: unknown): value is GpuDevicePlugin {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
return obj['kind'] === 'GpuDevicePlugin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Node (with GPU resource fields)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface NodeResources {
|
||||||
|
[key: string]: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeStatus {
|
||||||
|
capacity?: NodeResources;
|
||||||
|
allocatable?: NodeResources;
|
||||||
|
conditions?: Array<{
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
lastHeartbeatTime?: string;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
nodeInfo?: {
|
||||||
|
kernelVersion?: string;
|
||||||
|
osImage?: string;
|
||||||
|
architecture?: string;
|
||||||
|
kubeletVersion?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeSpec {
|
||||||
|
taints?: Array<{ key: string; effect: string; value?: string }>;
|
||||||
|
unschedulable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntelGpuNode extends KubeObject {
|
||||||
|
spec?: NodeSpec;
|
||||||
|
status?: NodeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the node has any Intel GPU resources in its capacity */
|
||||||
|
export function isIntelGpuNode(node: unknown): node is IntelGpuNode {
|
||||||
|
if (!node || typeof node !== 'object') return false;
|
||||||
|
const obj = node as Record<string, unknown>;
|
||||||
|
const meta = obj['metadata'] as Record<string, unknown> | undefined;
|
||||||
|
const labels = meta?.['labels'] as Record<string, string> | undefined;
|
||||||
|
const status = obj['status'] as Record<string, unknown> | undefined;
|
||||||
|
const capacity = status?.['capacity'] as Record<string, string> | undefined;
|
||||||
|
|
||||||
|
// Check node labels (added by Intel Node Feature Discovery)
|
||||||
|
if (labels) {
|
||||||
|
if (
|
||||||
|
labels[INTEL_GPU_NODE_LABEL] === 'true' ||
|
||||||
|
labels[INTEL_DISCRETE_GPU_NODE_ROLE] === 'true' ||
|
||||||
|
labels[INTEL_INTEGRATED_GPU_NODE_ROLE] === 'true'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check node capacity for Intel GPU resources
|
||||||
|
if (capacity) {
|
||||||
|
for (const key of Object.keys(capacity)) {
|
||||||
|
if (key.startsWith(INTEL_GPU_RESOURCE_PREFIX)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterIntelGpuNodes(items: unknown[]): IntelGpuNode[] {
|
||||||
|
return items.filter(isIntelGpuNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all Intel GPU resource entries from a node's capacity/allocatable */
|
||||||
|
export function getGpuResources(resources: NodeResources | undefined): Record<string, string> {
|
||||||
|
if (!resources) return {};
|
||||||
|
const gpuResources: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(resources)) {
|
||||||
|
if (key.startsWith(INTEL_GPU_RESOURCE_PREFIX) && value !== undefined) {
|
||||||
|
gpuResources[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gpuResources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get total GPU count from node capacity */
|
||||||
|
export function getNodeGpuCount(node: IntelGpuNode): number {
|
||||||
|
const capacity = node.status?.capacity ?? {};
|
||||||
|
let count = 0;
|
||||||
|
for (const [key, value] of Object.entries(capacity)) {
|
||||||
|
if ((key === INTEL_GPU_RESOURCE || key === INTEL_GPU_XE_RESOURCE) && value) {
|
||||||
|
count += parseInt(value, 10) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine GPU type from node labels */
|
||||||
|
export type GpuType = 'discrete' | 'integrated' | 'unknown';
|
||||||
|
|
||||||
|
export function getNodeGpuType(node: IntelGpuNode): GpuType {
|
||||||
|
const labels = node.metadata.labels ?? {};
|
||||||
|
if (labels[INTEL_DISCRETE_GPU_NODE_ROLE] === 'true') return 'discrete';
|
||||||
|
if (labels[INTEL_INTEGRATED_GPU_NODE_ROLE] === 'true') return 'integrated';
|
||||||
|
// Fallback: check for generic Intel GPU label
|
||||||
|
if (labels[INTEL_GPU_NODE_LABEL] === 'true') return 'unknown';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGpuType(type: GpuType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'discrete': return 'Discrete';
|
||||||
|
case 'integrated': return 'Integrated';
|
||||||
|
default: return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pod (with GPU resource requests)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ResourceRequirements {
|
||||||
|
requests?: Record<string, string>;
|
||||||
|
limits?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerSpec {
|
||||||
|
name: string;
|
||||||
|
image?: string;
|
||||||
|
resources?: ResourceRequirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerStatus {
|
||||||
|
name: string;
|
||||||
|
ready: boolean;
|
||||||
|
restartCount: number;
|
||||||
|
image?: string;
|
||||||
|
state?: {
|
||||||
|
running?: { startedAt?: string };
|
||||||
|
waiting?: { reason?: string; message?: string };
|
||||||
|
terminated?: { exitCode?: number; reason?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodSpec {
|
||||||
|
nodeName?: string;
|
||||||
|
containers?: ContainerSpec[];
|
||||||
|
initContainers?: ContainerSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodStatus {
|
||||||
|
phase?: string;
|
||||||
|
conditions?: Array<{ type: string; status: string }>;
|
||||||
|
containerStatuses?: ContainerStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntelGpuPod extends KubeObject {
|
||||||
|
spec?: PodSpec;
|
||||||
|
status?: PodStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if any container in the pod requests Intel GPU resources */
|
||||||
|
export function isGpuRequestingPod(pod: unknown): pod is IntelGpuPod {
|
||||||
|
if (!pod || typeof pod !== 'object') return false;
|
||||||
|
const obj = pod as Record<string, unknown>;
|
||||||
|
const spec = obj['spec'] as Record<string, unknown> | undefined;
|
||||||
|
const containers = (spec?.['containers'] ?? []) as ContainerSpec[];
|
||||||
|
const initContainers = (spec?.['initContainers'] ?? []) as ContainerSpec[];
|
||||||
|
|
||||||
|
return [...containers, ...initContainers].some(c => {
|
||||||
|
const requests = c.resources?.requests ?? {};
|
||||||
|
const limits = c.resources?.limits ?? {};
|
||||||
|
return Object.keys({ ...requests, ...limits }).some(k =>
|
||||||
|
k.startsWith(INTEL_GPU_RESOURCE_PREFIX)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterGpuRequestingPods(items: unknown[]): IntelGpuPod[] {
|
||||||
|
return items.filter(isGpuRequestingPod);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if any container in the pod requests Intel GPU resources (for plugin pods) */
|
||||||
|
export function isIntelGpuPluginPod(pod: unknown): pod is IntelGpuPod {
|
||||||
|
if (!pod || typeof pod !== 'object') return false;
|
||||||
|
const obj = pod as Record<string, unknown>;
|
||||||
|
const meta = obj['metadata'] as Record<string, unknown> | undefined;
|
||||||
|
const labels = meta?.['labels'] as Record<string, string> | undefined;
|
||||||
|
if (!labels) return false;
|
||||||
|
return labels['app'] === 'intel-gpu-plugin' ||
|
||||||
|
(labels['app.kubernetes.io/name'] === 'intel-gpu-plugin') ||
|
||||||
|
(labels['component'] === 'intel-gpu-plugin');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterIntelGpuPluginPods(items: unknown[]): IntelGpuPod[] {
|
||||||
|
return items.filter(isIntelGpuPluginPod);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get total GPU requests from a pod's containers */
|
||||||
|
export function getPodGpuRequests(pod: IntelGpuPod): Record<string, string> {
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
const allContainers = [
|
||||||
|
...(pod.spec?.containers ?? []),
|
||||||
|
...(pod.spec?.initContainers ?? []),
|
||||||
|
];
|
||||||
|
for (const c of allContainers) {
|
||||||
|
const requests = c.resources?.requests ?? {};
|
||||||
|
for (const [key, value] of Object.entries(requests)) {
|
||||||
|
if (key.startsWith(INTEL_GPU_RESOURCE_PREFIX) && value) {
|
||||||
|
totals[key] = (totals[key] ?? 0) + (parseInt(value, 10) || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.fromEntries(Object.entries(totals).map(([k, v]) => [k, String(v)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPodReady(pod: IntelGpuPod): boolean {
|
||||||
|
return (
|
||||||
|
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPodRestarts(pod: IntelGpuPod): number {
|
||||||
|
return (
|
||||||
|
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// K8s API list response envelope
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface KubeList<T> {
|
||||||
|
items: T[];
|
||||||
|
metadata?: { resourceVersion?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKubeList(value: unknown): value is KubeList<unknown> {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
return Array.isArray((value as Record<string, unknown>)['items']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Node condition helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function isNodeReady(node: IntelGpuNode): boolean {
|
||||||
|
return (
|
||||||
|
node.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utility: human-readable age
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function formatAge(timestamp: string | undefined): string {
|
||||||
|
if (!timestamp) return 'unknown';
|
||||||
|
const diffMs = Date.now() - new Date(timestamp).getTime();
|
||||||
|
const secs = Math.floor(diffMs / 1000);
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
const mins = Math.floor(secs / 60);
|
||||||
|
if (mins < 60) return `${mins}m`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utility: GPU resource display name
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function formatGpuResourceName(resourceKey: string): string {
|
||||||
|
const name = resourceKey.replace(INTEL_GPU_RESOURCE_PREFIX, '');
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'i915': 'GPU (i915)',
|
||||||
|
'xe': 'GPU (Xe)',
|
||||||
|
'millicores': 'GPU Millicores',
|
||||||
|
'memory.max': 'GPU Memory (max)',
|
||||||
|
'tiles': 'GPU Tiles',
|
||||||
|
};
|
||||||
|
return map[name] ?? name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function pluginStatusToStatus(
|
||||||
|
plugin: GpuDevicePlugin
|
||||||
|
): 'success' | 'warning' | 'error' {
|
||||||
|
const desired = plugin.status?.desiredNumberScheduled ?? 0;
|
||||||
|
const ready = plugin.status?.numberReady ?? 0;
|
||||||
|
const unavailable = plugin.status?.numberUnavailable ?? 0;
|
||||||
|
|
||||||
|
if (desired === 0) return 'warning';
|
||||||
|
if (unavailable > 0) return 'warning';
|
||||||
|
if (ready === desired) return 'success';
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginStatusText(plugin: GpuDevicePlugin): string {
|
||||||
|
const desired = plugin.status?.desiredNumberScheduled ?? 0;
|
||||||
|
const ready = plugin.status?.numberReady ?? 0;
|
||||||
|
if (desired === 0) return 'No nodes scheduled';
|
||||||
|
return `${ready}/${desired} ready`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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,212 @@
|
|||||||
|
/**
|
||||||
|
* DevicePluginsPage — lists all GpuDevicePlugin CRD instances.
|
||||||
|
*
|
||||||
|
* Shows configuration details for each Intel GPU device plugin deployment,
|
||||||
|
* including spec and status information.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader,
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
SectionHeader,
|
||||||
|
SimpleTable,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import { formatAge, isPodReady, pluginStatusText, pluginStatusToStatus } from '../api/k8s';
|
||||||
|
|
||||||
|
export default function DevicePluginsPage() {
|
||||||
|
const { devicePlugins, pluginPods, crdAvailable, loading, error, refresh } =
|
||||||
|
useIntelGpuContext();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loader title="Loading device plugin data..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<SectionHeader title="Intel GPU — Device Plugins" />
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
aria-label="Refresh device plugin data"
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--mui-palette-primary-main, #0071c5)',
|
||||||
|
border: '1px solid var(--mui-palette-primary-main, #0071c5)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!crdAvailable && (
|
||||||
|
<SectionBox title="CRD Not Available">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status="warning">
|
||||||
|
GpuDevicePlugin CRD (deviceplugin.intel.com/v1) is not installed
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Note',
|
||||||
|
value:
|
||||||
|
'Install the Intel Device Plugins Operator to manage GpuDevicePlugin resources. ' +
|
||||||
|
'Plugin daemon pods are shown below if detected.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GpuDevicePlugin CRD instances */}
|
||||||
|
{crdAvailable && devicePlugins.length === 0 && (
|
||||||
|
<SectionBox title="No Device Plugins">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status="warning">
|
||||||
|
No GpuDevicePlugin resources found on this cluster
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
value:
|
||||||
|
'kubectl apply -f gpudeviceplugin.yaml (see Intel documentation for configuration)',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{devicePlugins.map(plugin => (
|
||||||
|
<SectionBox key={plugin.metadata.uid ?? plugin.metadata.name} title={`GpuDevicePlugin: ${plugin.metadata.name}`}>
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={pluginStatusToStatus(plugin)}>
|
||||||
|
{pluginStatusText(plugin)}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Image',
|
||||||
|
value: plugin.spec.image ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Shared Devices/Node',
|
||||||
|
value: String(plugin.spec.sharedDevNum ?? 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Allocation Policy',
|
||||||
|
value: plugin.spec.preferredAllocationPolicy ?? 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Monitoring',
|
||||||
|
value: plugin.spec.enableMonitoring ? (
|
||||||
|
<StatusLabel status="success">Enabled</StatusLabel>
|
||||||
|
) : (
|
||||||
|
<StatusLabel status="warning">Disabled</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Resource Manager',
|
||||||
|
value: plugin.spec.resourceManager ? 'Enabled' : 'Disabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Desired Nodes',
|
||||||
|
value: String(plugin.status?.desiredNumberScheduled ?? '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ready Nodes',
|
||||||
|
value: String(plugin.status?.numberReady ?? '—'),
|
||||||
|
},
|
||||||
|
...(plugin.status?.numberUnavailable
|
||||||
|
? [{
|
||||||
|
name: 'Unavailable Nodes',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status="error">
|
||||||
|
{plugin.status.numberUnavailable}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
name: 'Node Selector',
|
||||||
|
value: plugin.spec.nodeSelector
|
||||||
|
? Object.entries(plugin.spec.nodeSelector)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(', ')
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Age',
|
||||||
|
value: formatAge(plugin.metadata.creationTimestamp),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Plugin daemon pods */}
|
||||||
|
{pluginPods.length > 0 && (
|
||||||
|
<SectionBox title="Plugin Daemon Pods">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||||
|
{ label: 'Namespace', getter: (p) => p.metadata.namespace ?? '—' },
|
||||||
|
{ label: 'Node', getter: (p) => p.spec?.nodeName ?? '—' },
|
||||||
|
{
|
||||||
|
label: 'Ready',
|
||||||
|
getter: (p) => (
|
||||||
|
<StatusLabel status={isPodReady(p) ? 'success' : 'warning'}>
|
||||||
|
{isPodReady(p) ? 'Ready' : p.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Restarts',
|
||||||
|
getter: (p) => {
|
||||||
|
const restarts = p.status?.containerStatuses?.reduce(
|
||||||
|
(sum, c) => sum + c.restartCount, 0
|
||||||
|
) ?? 0;
|
||||||
|
return restarts > 0 ? (
|
||||||
|
<StatusLabel status="warning">{restarts}</StatusLabel>
|
||||||
|
) : (
|
||||||
|
String(restarts)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={pluginPods}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* NodeDetailSection — injected into Headlamp's native Node detail page.
|
||||||
|
*
|
||||||
|
* Shows Intel GPU resources available on the node (capacity, allocatable),
|
||||||
|
* GPU type, and pods currently using GPU resources on this node.
|
||||||
|
* Returns null for non-GPU nodes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import {
|
||||||
|
formatGpuResourceName,
|
||||||
|
formatGpuType,
|
||||||
|
getGpuResources,
|
||||||
|
getNodeGpuType,
|
||||||
|
INTEL_GPU_RESOURCE,
|
||||||
|
INTEL_GPU_RESOURCE_PREFIX,
|
||||||
|
INTEL_GPU_XE_RESOURCE,
|
||||||
|
isIntelGpuNode,
|
||||||
|
isNodeReady,
|
||||||
|
} from '../api/k8s';
|
||||||
|
|
||||||
|
interface NodeDetailSectionProps {
|
||||||
|
resource: {
|
||||||
|
kind?: string;
|
||||||
|
metadata?: { name?: string; labels?: Record<string, string> };
|
||||||
|
jsonData?: unknown;
|
||||||
|
// Headlamp KubeObject may expose status directly or via jsonData
|
||||||
|
status?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NodeDetailSection({ resource }: NodeDetailSectionProps) {
|
||||||
|
const { gpuPods, loading } = useIntelGpuContext();
|
||||||
|
|
||||||
|
// Extract the raw Kubernetes JSON — Headlamp KubeObject wraps it in jsonData
|
||||||
|
const rawNode =
|
||||||
|
resource.jsonData && typeof resource.jsonData === 'object'
|
||||||
|
? resource.jsonData
|
||||||
|
: resource;
|
||||||
|
|
||||||
|
// Only render for Node resources that have Intel GPU
|
||||||
|
if (!isIntelGpuNode(rawNode)) return null;
|
||||||
|
|
||||||
|
const node = rawNode as Parameters<typeof isIntelGpuNode>[0] & {
|
||||||
|
status?: {
|
||||||
|
capacity?: Record<string, string>;
|
||||||
|
allocatable?: Record<string, string>;
|
||||||
|
nodeInfo?: { kernelVersion?: string; osImage?: string };
|
||||||
|
};
|
||||||
|
metadata: { name: string; labels?: Record<string, string> };
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeName = (node as { metadata: { name: string } }).metadata.name;
|
||||||
|
const capacity = getGpuResources((node as any).status?.capacity);
|
||||||
|
const allocatable = getGpuResources((node as any).status?.allocatable);
|
||||||
|
|
||||||
|
const gpuType = getNodeGpuType(node as any);
|
||||||
|
|
||||||
|
// Find GPU pods scheduled on this node
|
||||||
|
const podsOnNode = loading
|
||||||
|
? []
|
||||||
|
: gpuPods.filter(p => p.spec?.nodeName === nodeName);
|
||||||
|
|
||||||
|
if (Object.keys(capacity).length === 0 && Object.keys(allocatable).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPU utilization: count GPU units used by running pods
|
||||||
|
let gpuInUse = 0;
|
||||||
|
let gpuAllocatable = 0;
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(allocatable)) {
|
||||||
|
if (key === INTEL_GPU_RESOURCE || key === INTEL_GPU_XE_RESOURCE) {
|
||||||
|
gpuAllocatable += parseInt(val, 10) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pod of podsOnNode.filter(p => p.status?.phase === 'Running')) {
|
||||||
|
const reqs = pod.spec?.containers?.flatMap(c =>
|
||||||
|
Object.entries(c.resources?.requests ?? {}).filter(([k]) =>
|
||||||
|
k === INTEL_GPU_RESOURCE || k === INTEL_GPU_XE_RESOURCE
|
||||||
|
)
|
||||||
|
) ?? [];
|
||||||
|
for (const [, val] of reqs) {
|
||||||
|
gpuInUse += parseInt(val, 10) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const utilizationPct =
|
||||||
|
gpuAllocatable > 0 ? Math.round((gpuInUse / gpuAllocatable) * 100) : 0;
|
||||||
|
const utilizationStatus: 'success' | 'warning' | 'error' =
|
||||||
|
utilizationPct >= 90 ? 'error' : utilizationPct >= 70 ? 'warning' : 'success';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionBox title="Intel GPU">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'GPU Type',
|
||||||
|
value: formatGpuType(gpuType),
|
||||||
|
},
|
||||||
|
// Capacity rows
|
||||||
|
...Object.entries(capacity).map(([key, val]) => ({
|
||||||
|
name: `${formatGpuResourceName(key)} (capacity)`,
|
||||||
|
value: val,
|
||||||
|
})),
|
||||||
|
// Allocatable rows
|
||||||
|
...Object.entries(allocatable).map(([key, val]) => ({
|
||||||
|
name: `${formatGpuResourceName(key)} (allocatable)`,
|
||||||
|
value: val,
|
||||||
|
})),
|
||||||
|
// Utilization
|
||||||
|
...(gpuAllocatable > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'GPU Utilization',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={utilizationStatus}>
|
||||||
|
{`${gpuInUse}/${gpuAllocatable} (${utilizationPct}%)`}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
// Workload pods
|
||||||
|
{
|
||||||
|
name: 'GPU Workload Pods',
|
||||||
|
value:
|
||||||
|
podsOnNode.length > 0
|
||||||
|
? podsOnNode.map(p => p.metadata.name).join(', ')
|
||||||
|
: loading
|
||||||
|
? 'Loading…'
|
||||||
|
: 'None',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* NodesPage — lists all nodes with Intel GPU capabilities.
|
||||||
|
*
|
||||||
|
* Shows GPU type, device count, resource allocation, and pod assignments
|
||||||
|
* for each GPU-capable node in the cluster.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader,
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
SectionHeader,
|
||||||
|
SimpleTable,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import {
|
||||||
|
formatAge,
|
||||||
|
formatGpuResourceName,
|
||||||
|
formatGpuType,
|
||||||
|
getGpuResources,
|
||||||
|
getNodeGpuCount,
|
||||||
|
getNodeGpuType,
|
||||||
|
INTEL_GPU_RESOURCE,
|
||||||
|
INTEL_GPU_RESOURCE_PREFIX,
|
||||||
|
INTEL_GPU_XE_RESOURCE,
|
||||||
|
IntelGpuNode,
|
||||||
|
isNodeReady,
|
||||||
|
} from '../api/k8s';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GPU allocation bar component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function GpuAllocationBar({
|
||||||
|
used,
|
||||||
|
allocatable,
|
||||||
|
}: {
|
||||||
|
used: number;
|
||||||
|
allocatable: number;
|
||||||
|
}) {
|
||||||
|
if (allocatable === 0) return <span>—</span>;
|
||||||
|
const pct = Math.min(100, Math.round((used / allocatable) * 100));
|
||||||
|
const color = pct >= 90 ? '#d32f2f' : pct >= 70 ? '#f57c00' : '#0071c5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '80px',
|
||||||
|
height: '8px',
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: color,
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '12px' }}>{`${used}/${allocatable} (${pct}%)`}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Node detail card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function NodeDetailCard({
|
||||||
|
node,
|
||||||
|
podsByNode,
|
||||||
|
}: {
|
||||||
|
node: IntelGpuNode;
|
||||||
|
podsByNode: Map<string, string[]>;
|
||||||
|
}) {
|
||||||
|
const gpuType = getNodeGpuType(node);
|
||||||
|
const gpuCount = getNodeGpuCount(node);
|
||||||
|
const ready = isNodeReady(node);
|
||||||
|
|
||||||
|
const capacityResources = getGpuResources(node.status?.capacity);
|
||||||
|
const allocatableResources = getGpuResources(node.status?.allocatable);
|
||||||
|
|
||||||
|
const podsOnNode = podsByNode.get(node.metadata.name) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionBox title={node.metadata.name}>
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={ready ? 'success' : 'error'}>
|
||||||
|
{ready ? 'Ready' : 'Not Ready'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GPU Type',
|
||||||
|
value: formatGpuType(gpuType),
|
||||||
|
},
|
||||||
|
...(gpuCount > 0
|
||||||
|
? [{ name: 'GPU Devices (i915/xe)', value: String(gpuCount) }]
|
||||||
|
: []),
|
||||||
|
...Object.entries(capacityResources).map(([key, cap]) => {
|
||||||
|
const alloc = parseInt(allocatableResources[key] ?? '0', 10);
|
||||||
|
const total = parseInt(cap, 10);
|
||||||
|
return {
|
||||||
|
name: `${formatGpuResourceName(key)} (capacity)`,
|
||||||
|
value: String(total),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...Object.entries(allocatableResources).map(([key, alloc]) => {
|
||||||
|
return {
|
||||||
|
name: `${formatGpuResourceName(key)} (allocatable)`,
|
||||||
|
value: alloc ?? '0',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'GPU Workload Pods',
|
||||||
|
value: podsOnNode.length > 0 ? podsOnNode.join(', ') : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OS Image',
|
||||||
|
value: node.status?.nodeInfo?.osImage ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Kernel',
|
||||||
|
value: node.status?.nodeInfo?.kernelVersion ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Kubelet',
|
||||||
|
value: node.status?.nodeInfo?.kubeletVersion ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Age',
|
||||||
|
value: formatAge(node.metadata.creationTimestamp),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function NodesPage() {
|
||||||
|
const { gpuNodes, gpuPods, loading, error, refresh } = useIntelGpuContext();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loader title="Loading GPU node data..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build map: nodeName → list of GPU pod names
|
||||||
|
const podsByNode = new Map<string, string[]>();
|
||||||
|
for (const pod of gpuPods) {
|
||||||
|
if (!pod.spec?.nodeName) continue;
|
||||||
|
const existing = podsByNode.get(pod.spec.nodeName) ?? [];
|
||||||
|
existing.push(pod.metadata.name);
|
||||||
|
podsByNode.set(pod.spec.nodeName, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build table data for summary
|
||||||
|
const tableData = gpuNodes.map(node => {
|
||||||
|
const gpuType = getNodeGpuType(node);
|
||||||
|
const gpuCount = getNodeGpuCount(node);
|
||||||
|
const ready = isNodeReady(node);
|
||||||
|
const capacity = node.status?.capacity ?? {};
|
||||||
|
const allocatable = node.status?.allocatable ?? {};
|
||||||
|
|
||||||
|
let totalCapacity = 0;
|
||||||
|
let totalAllocatable = 0;
|
||||||
|
for (const key of Object.keys(capacity)) {
|
||||||
|
if (key === INTEL_GPU_RESOURCE || key === INTEL_GPU_XE_RESOURCE) {
|
||||||
|
totalCapacity += parseInt(capacity[key] ?? '0', 10);
|
||||||
|
totalAllocatable += parseInt(allocatable[key] ?? '0', 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const podsOnNode = podsByNode.get(node.metadata.name) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
gpuType,
|
||||||
|
gpuCount,
|
||||||
|
ready,
|
||||||
|
totalCapacity,
|
||||||
|
totalAllocatable,
|
||||||
|
podsOnNode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<SectionHeader title="Intel GPU — Nodes" />
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
aria-label="Refresh node data"
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--mui-palette-primary-main, #0071c5)',
|
||||||
|
border: '1px solid var(--mui-palette-primary-main, #0071c5)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gpuNodes.length === 0 && (
|
||||||
|
<SectionBox title="No GPU Nodes Found">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status="warning">
|
||||||
|
No nodes with Intel GPU resources or labels were found
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Note',
|
||||||
|
value:
|
||||||
|
'Nodes appear here when they have gpu.intel.com/* resources or Intel GPU node labels. ' +
|
||||||
|
'Ensure the Intel GPU device plugin and Node Feature Discovery are installed.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary table */}
|
||||||
|
{gpuNodes.length > 0 && (
|
||||||
|
<SectionBox title="GPU Node Summary">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Node', getter: (d) => d.node.metadata.name },
|
||||||
|
{
|
||||||
|
label: 'Ready',
|
||||||
|
getter: (d) => (
|
||||||
|
<StatusLabel status={d.ready ? 'success' : 'error'}>
|
||||||
|
{d.ready ? 'Ready' : 'Not Ready'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'GPU Type', getter: (d) => formatGpuType(d.gpuType) },
|
||||||
|
{ label: 'GPU Devices', getter: (d) => String(d.gpuCount || '—') },
|
||||||
|
{
|
||||||
|
label: 'Allocation',
|
||||||
|
getter: (d) => (
|
||||||
|
<GpuAllocationBar
|
||||||
|
used={d.podsOnNode.length}
|
||||||
|
allocatable={d.totalAllocatable || d.gpuCount}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'GPU Pods', getter: (d) => String(d.podsOnNode.length) },
|
||||||
|
{ label: 'Age', getter: (d) => formatAge(d.node.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={tableData}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-node detail cards */}
|
||||||
|
{gpuNodes.map(node => (
|
||||||
|
<NodeDetailCard
|
||||||
|
key={node.metadata.uid ?? node.metadata.name}
|
||||||
|
node={node}
|
||||||
|
podsByNode={podsByNode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* OverviewPage — main dashboard for the Intel GPU plugin.
|
||||||
|
*
|
||||||
|
* Shows: plugin health, GPU node summary, resource allocation overview,
|
||||||
|
* and pods requesting GPU resources.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader,
|
||||||
|
NameValueTable,
|
||||||
|
PercentageBar,
|
||||||
|
SectionBox,
|
||||||
|
SectionHeader,
|
||||||
|
SimpleTable,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import {
|
||||||
|
formatAge,
|
||||||
|
formatGpuType,
|
||||||
|
getNodeGpuCount,
|
||||||
|
getNodeGpuType,
|
||||||
|
getPodGpuRequests,
|
||||||
|
INTEL_GPU_RESOURCE,
|
||||||
|
INTEL_GPU_RESOURCE_PREFIX,
|
||||||
|
INTEL_GPU_XE_RESOURCE,
|
||||||
|
isNodeReady,
|
||||||
|
isPodReady,
|
||||||
|
pluginStatusText,
|
||||||
|
pluginStatusToStatus,
|
||||||
|
} from '../api/k8s';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GPU type distribution chart
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function gpuTypeChartData(
|
||||||
|
discreteCount: number,
|
||||||
|
integratedCount: number,
|
||||||
|
unknownCount: number
|
||||||
|
): Array<{ name: string; value: number; fill: string }> {
|
||||||
|
const data = [];
|
||||||
|
if (discreteCount > 0) data.push({ name: 'Discrete', value: discreteCount, fill: '#0071c5' });
|
||||||
|
if (integratedCount > 0) data.push({ name: 'Integrated', value: integratedCount, fill: '#60a4dc' });
|
||||||
|
if (unknownCount > 0) data.push({ name: 'Unknown', value: unknownCount, fill: '#9e9e9e' });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function OverviewPage() {
|
||||||
|
const {
|
||||||
|
devicePlugins,
|
||||||
|
pluginInstalled,
|
||||||
|
gpuNodes,
|
||||||
|
gpuPods,
|
||||||
|
pluginPods,
|
||||||
|
crdAvailable,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
} = useIntelGpuContext();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loader title="Loading Intel GPU data..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node type breakdown
|
||||||
|
let discreteCount = 0;
|
||||||
|
let integratedCount = 0;
|
||||||
|
let unknownCount = 0;
|
||||||
|
let totalGpuCount = 0;
|
||||||
|
let readyNodeCount = 0;
|
||||||
|
|
||||||
|
for (const node of gpuNodes) {
|
||||||
|
const type = getNodeGpuType(node);
|
||||||
|
if (type === 'discrete') discreteCount++;
|
||||||
|
else if (type === 'integrated') integratedCount++;
|
||||||
|
else unknownCount++;
|
||||||
|
|
||||||
|
totalGpuCount += getNodeGpuCount(node);
|
||||||
|
if (isNodeReady(node)) readyNodeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPU allocation summary: sum capacity vs allocatable across all GPU nodes
|
||||||
|
let totalCapacityGpus = 0;
|
||||||
|
let totalAllocatableGpus = 0;
|
||||||
|
let totalAllocatedGpus = 0;
|
||||||
|
|
||||||
|
for (const node of gpuNodes) {
|
||||||
|
const capacity = node.status?.capacity ?? {};
|
||||||
|
const allocatable = node.status?.allocatable ?? {};
|
||||||
|
for (const key of Object.keys(capacity)) {
|
||||||
|
if (key === INTEL_GPU_RESOURCE || key === INTEL_GPU_XE_RESOURCE) {
|
||||||
|
totalCapacityGpus += parseInt(capacity[key] ?? '0', 10);
|
||||||
|
totalAllocatableGpus += parseInt(allocatable[key] ?? '0', 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count GPUs in use from pods
|
||||||
|
for (const pod of gpuPods) {
|
||||||
|
if (pod.status?.phase !== 'Running') continue;
|
||||||
|
const requests = getPodGpuRequests(pod);
|
||||||
|
for (const [key, value] of Object.entries(requests)) {
|
||||||
|
if (key === INTEL_GPU_RESOURCE || key === INTEL_GPU_XE_RESOURCE) {
|
||||||
|
totalAllocatedGpus += parseInt(value, 10) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpuUtilizationPct =
|
||||||
|
totalCapacityGpus > 0
|
||||||
|
? Math.round((totalAllocatedGpus / totalCapacityGpus) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const chartData = gpuTypeChartData(discreteCount, integratedCount, unknownCount);
|
||||||
|
const totalGpuNodes = gpuNodes.length;
|
||||||
|
|
||||||
|
// Pod phase breakdown
|
||||||
|
const podPhaseCounts = { Running: 0, Pending: 0, Succeeded: 0, Failed: 0, Other: 0 };
|
||||||
|
for (const pod of gpuPods) {
|
||||||
|
const phase = pod.status?.phase ?? 'Other';
|
||||||
|
if (phase in podPhaseCounts) {
|
||||||
|
podPhaseCounts[phase as keyof typeof podPhaseCounts]++;
|
||||||
|
} else {
|
||||||
|
podPhaseCounts.Other++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<SectionHeader title="Intel GPU — Overview" />
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
aria-label="Refresh Intel GPU data"
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--mui-palette-primary-main, #0071c5)',
|
||||||
|
border: '1px solid var(--mui-palette-primary-main, #0071c5)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plugin not detected */}
|
||||||
|
{!pluginInstalled && !loading && (
|
||||||
|
<SectionBox title="Plugin Not Detected">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status="warning">
|
||||||
|
Intel GPU device plugin not found on this cluster
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Install (Helm)',
|
||||||
|
value:
|
||||||
|
'helm repo add intel https://intel.github.io/helm-charts && ' +
|
||||||
|
'helm install intel-device-plugins-operator intel/intel-device-plugins-operator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Documentation',
|
||||||
|
value: 'https://intel.github.io/intel-device-plugins-for-kubernetes/',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CRD not available notice */}
|
||||||
|
{!crdAvailable && pluginInstalled && (
|
||||||
|
<SectionBox title="Notice">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'CRD Status',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status="warning">
|
||||||
|
GpuDevicePlugin CRD not found — limited visibility available
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Note',
|
||||||
|
value:
|
||||||
|
'Plugin pods detected via DaemonSet labels. Install the Intel Device Plugins Operator for full CRD-based management.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Device Plugin status — only shown when CRDs exist */}
|
||||||
|
{crdAvailable && devicePlugins.length > 0 && (
|
||||||
|
<SectionBox title="Device Plugin Status">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||||
|
{
|
||||||
|
label: 'Status',
|
||||||
|
getter: (p) => (
|
||||||
|
<StatusLabel status={pluginStatusToStatus(p)}>
|
||||||
|
{pluginStatusText(p)}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
getter: (p) => p.spec.enableMonitoring ? (
|
||||||
|
<StatusLabel status="success">Enabled</StatusLabel>
|
||||||
|
) : (
|
||||||
|
<StatusLabel status="warning">Disabled</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Shared/Node', getter: (p) => String(p.spec.sharedDevNum ?? 1) },
|
||||||
|
{ label: 'Policy', getter: (p) => p.spec.preferredAllocationPolicy ?? '—' },
|
||||||
|
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={devicePlugins}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plugin daemon pods (shown when no CRD, or always as supplemental) */}
|
||||||
|
{pluginPods.length > 0 && (
|
||||||
|
<SectionBox title="Plugin Daemon Pods">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||||
|
{ label: 'Namespace', getter: (p) => p.metadata.namespace ?? '—' },
|
||||||
|
{ label: 'Node', getter: (p) => p.spec?.nodeName ?? '—' },
|
||||||
|
{
|
||||||
|
label: 'Status',
|
||||||
|
getter: (p) => (
|
||||||
|
<StatusLabel status={isPodReady(p) ? 'success' : 'warning'}>
|
||||||
|
{isPodReady(p) ? 'Ready' : p.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={pluginPods}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GPU Node summary */}
|
||||||
|
<SectionBox title="GPU Nodes">
|
||||||
|
{totalGpuNodes > 0 && chartData.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||||
|
GPU Type Distribution
|
||||||
|
</div>
|
||||||
|
<PercentageBar data={chartData} total={totalGpuNodes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Total GPU Nodes',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={totalGpuNodes > 0 ? 'success' : 'warning'}>
|
||||||
|
{totalGpuNodes}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ name: 'Ready Nodes', value: String(readyNodeCount) },
|
||||||
|
...(discreteCount > 0 ? [{ name: 'Discrete GPU Nodes', value: String(discreteCount) }] : []),
|
||||||
|
...(integratedCount > 0 ? [{ name: 'Integrated GPU Nodes', value: String(integratedCount) }] : []),
|
||||||
|
...(totalGpuCount > 0 ? [{ name: 'Total GPU Devices', value: String(totalGpuCount) }] : []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
|
||||||
|
{/* GPU allocation summary */}
|
||||||
|
{totalCapacityGpus > 0 && (
|
||||||
|
<SectionBox title="GPU Allocation">
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||||
|
GPU Utilization ({gpuUtilizationPct}%)
|
||||||
|
</div>
|
||||||
|
<PercentageBar
|
||||||
|
data={[
|
||||||
|
{ name: 'In Use', value: totalAllocatedGpus, fill: '#0071c5' },
|
||||||
|
{ name: 'Available', value: totalAllocatableGpus - totalAllocatedGpus, fill: '#e0e0e0' },
|
||||||
|
]}
|
||||||
|
total={totalAllocatableGpus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Total Capacity (GPU devices)', value: String(totalCapacityGpus) },
|
||||||
|
{ name: 'Allocatable', value: String(totalAllocatableGpus) },
|
||||||
|
{ name: 'In Use', value: String(totalAllocatedGpus) },
|
||||||
|
{
|
||||||
|
name: 'Free',
|
||||||
|
value: (
|
||||||
|
<StatusLabel
|
||||||
|
status={totalAllocatableGpus - totalAllocatedGpus > 0 ? 'success' : 'warning'}
|
||||||
|
>
|
||||||
|
{totalAllocatableGpus - totalAllocatedGpus}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GPU workloads summary */}
|
||||||
|
<SectionBox title="GPU Workloads">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Total GPU Pods', value: String(gpuPods.length) },
|
||||||
|
...(podPhaseCounts.Running > 0
|
||||||
|
? [{ name: 'Running', value: <StatusLabel status="success">{podPhaseCounts.Running}</StatusLabel> }]
|
||||||
|
: []),
|
||||||
|
...(podPhaseCounts.Pending > 0
|
||||||
|
? [{ name: 'Pending', value: <StatusLabel status="warning">{podPhaseCounts.Pending}</StatusLabel> }]
|
||||||
|
: []),
|
||||||
|
...(podPhaseCounts.Failed > 0
|
||||||
|
? [{ name: 'Failed', value: <StatusLabel status="error">{podPhaseCounts.Failed}</StatusLabel> }]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
|
||||||
|
{/* Active GPU pods list (running only, trimmed to top 10) */}
|
||||||
|
{gpuPods.filter(p => p.status?.phase === 'Running').length > 0 && (
|
||||||
|
<SectionBox title="Active GPU Pods">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||||
|
{ label: 'Namespace', getter: (p) => p.metadata.namespace ?? '—' },
|
||||||
|
{ label: 'Node', getter: (p) => p.spec?.nodeName ?? '—' },
|
||||||
|
{
|
||||||
|
label: 'GPU Request',
|
||||||
|
getter: (p) => {
|
||||||
|
const reqs = getPodGpuRequests(p);
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [key, val] of Object.entries(reqs)) {
|
||||||
|
const shortKey = key.replace(INTEL_GPU_RESOURCE_PREFIX, '');
|
||||||
|
parts.push(`${shortKey}: ${val}`);
|
||||||
|
}
|
||||||
|
return parts.join(', ') || '—';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={gpuPods.filter(p => p.status?.phase === 'Running').slice(0, 10)}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* PodDetailSection — injected into Headlamp's native Pod detail page.
|
||||||
|
*
|
||||||
|
* Shows Intel GPU resource requests and limits per container, plus
|
||||||
|
* a link to the node's GPU summary.
|
||||||
|
* Returns null for pods that don't request Intel GPU resources.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { formatGpuResourceName, INTEL_GPU_RESOURCE_PREFIX, isGpuRequestingPod } from '../api/k8s';
|
||||||
|
|
||||||
|
interface PodDetailSectionProps {
|
||||||
|
resource: {
|
||||||
|
kind?: string;
|
||||||
|
metadata?: { name?: string; namespace?: string };
|
||||||
|
jsonData?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PodDetailSection({ resource }: PodDetailSectionProps) {
|
||||||
|
// Extract raw Kubernetes JSON
|
||||||
|
const rawPod =
|
||||||
|
resource.jsonData && typeof resource.jsonData === 'object'
|
||||||
|
? resource.jsonData
|
||||||
|
: resource;
|
||||||
|
|
||||||
|
// Only render for pods that request Intel GPU resources
|
||||||
|
if (!isGpuRequestingPod(rawPod)) return null;
|
||||||
|
|
||||||
|
const pod = rawPod as {
|
||||||
|
metadata: { name: string; namespace?: string };
|
||||||
|
spec?: {
|
||||||
|
nodeName?: string;
|
||||||
|
containers?: Array<{
|
||||||
|
name: string;
|
||||||
|
resources?: {
|
||||||
|
requests?: Record<string, string>;
|
||||||
|
limits?: Record<string, string>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
status?: { phase?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const containers = pod.spec?.containers ?? [];
|
||||||
|
const gpuContainers = containers.filter(c => {
|
||||||
|
const all = { ...c.resources?.requests, ...c.resources?.limits };
|
||||||
|
return Object.keys(all).some(k => k.startsWith(INTEL_GPU_RESOURCE_PREFIX));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gpuContainers.length === 0) return null;
|
||||||
|
|
||||||
|
// Build rows: one per container per GPU resource
|
||||||
|
const rows: Array<{ name: string; value: React.ReactNode }> = [];
|
||||||
|
|
||||||
|
for (const c of gpuContainers) {
|
||||||
|
const requests = c.resources?.requests ?? {};
|
||||||
|
const limits = c.resources?.limits ?? {};
|
||||||
|
const allGpuKeys = new Set([
|
||||||
|
...Object.keys(requests).filter(k => k.startsWith(INTEL_GPU_RESOURCE_PREFIX)),
|
||||||
|
...Object.keys(limits).filter(k => k.startsWith(INTEL_GPU_RESOURCE_PREFIX)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const key of allGpuKeys) {
|
||||||
|
const req = requests[key];
|
||||||
|
const lim = limits[key];
|
||||||
|
const resourceName = formatGpuResourceName(key);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
name: `${c.name} → ${resourceName} request`,
|
||||||
|
value: req ?? '—',
|
||||||
|
});
|
||||||
|
if (lim && lim !== req) {
|
||||||
|
rows.push({
|
||||||
|
name: `${c.name} → ${resourceName} limit`,
|
||||||
|
value: lim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = pod.status?.phase;
|
||||||
|
const phaseStatus: 'success' | 'warning' | 'error' =
|
||||||
|
phase === 'Running' || phase === 'Succeeded'
|
||||||
|
? 'success'
|
||||||
|
: phase === 'Pending'
|
||||||
|
? 'warning'
|
||||||
|
: 'error';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionBox title="Intel GPU Resources">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Phase',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status={phaseStatus}>{phase ?? 'Unknown'}</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Scheduled Node',
|
||||||
|
value: pod.spec?.nodeName ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GPU Containers',
|
||||||
|
value: String(gpuContainers.length),
|
||||||
|
},
|
||||||
|
...rows,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* PodsPage — lists all pods requesting Intel GPU resources.
|
||||||
|
*
|
||||||
|
* Shows GPU resource requests/limits per container and pod-level status.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader,
|
||||||
|
NameValueTable,
|
||||||
|
SectionBox,
|
||||||
|
SectionHeader,
|
||||||
|
SimpleTable,
|
||||||
|
StatusLabel,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntelGpuContext } from '../api/IntelGpuDataContext';
|
||||||
|
import {
|
||||||
|
formatAge,
|
||||||
|
formatGpuResourceName,
|
||||||
|
IntelGpuPod,
|
||||||
|
INTEL_GPU_RESOURCE_PREFIX,
|
||||||
|
isPodReady,
|
||||||
|
getPodGpuRequests,
|
||||||
|
getPodRestarts,
|
||||||
|
} from '../api/k8s';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase → status mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function phaseToStatus(phase: string | undefined): 'success' | 'warning' | 'error' {
|
||||||
|
switch (phase) {
|
||||||
|
case 'Running': return 'success';
|
||||||
|
case 'Succeeded': return 'success';
|
||||||
|
case 'Pending': return 'warning';
|
||||||
|
case 'Failed': return 'error';
|
||||||
|
default: return 'warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GPU container list for a pod
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function GpuContainerList({ pod }: { pod: IntelGpuPod }) {
|
||||||
|
const containers = pod.spec?.containers ?? [];
|
||||||
|
const gpuContainers = containers.filter(c => {
|
||||||
|
const resources = { ...c.resources?.requests, ...c.resources?.limits };
|
||||||
|
return Object.keys(resources).some(k => k.startsWith(INTEL_GPU_RESOURCE_PREFIX));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gpuContainers.length === 0) return <span>—</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{gpuContainers.map(c => {
|
||||||
|
const requests = c.resources?.requests ?? {};
|
||||||
|
const limits = c.resources?.limits ?? {};
|
||||||
|
const gpuKeys = new Set([
|
||||||
|
...Object.keys(requests).filter(k => k.startsWith(INTEL_GPU_RESOURCE_PREFIX)),
|
||||||
|
...Object.keys(limits).filter(k => k.startsWith(INTEL_GPU_RESOURCE_PREFIX)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const key of gpuKeys) {
|
||||||
|
const shortKey = formatGpuResourceName(key);
|
||||||
|
const req = requests[key];
|
||||||
|
const lim = limits[key];
|
||||||
|
if (req && lim && req === lim) {
|
||||||
|
parts.push(`${shortKey}: ${req}`);
|
||||||
|
} else if (req || lim) {
|
||||||
|
parts.push(`${shortKey}: req=${req ?? '—'} lim=${lim ?? '—'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={c.name} style={{ marginBottom: '2px', fontSize: '13px' }}>
|
||||||
|
<strong>{c.name}</strong>: {parts.join(', ')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function PodsPage() {
|
||||||
|
const { gpuPods, loading, error, refresh } = useIntelGpuContext();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loader title="Loading GPU pod data..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by phase
|
||||||
|
const running = gpuPods.filter(p => p.status?.phase === 'Running');
|
||||||
|
const pending = gpuPods.filter(p => p.status?.phase === 'Pending');
|
||||||
|
const failed = gpuPods.filter(p => p.status?.phase === 'Failed');
|
||||||
|
const other = gpuPods.filter(
|
||||||
|
p => !['Running', 'Pending', 'Failed'].includes(p.status?.phase ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<SectionHeader title="Intel GPU — Pods" />
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
aria-label="Refresh pod data"
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--mui-palette-primary-main, #0071c5)',
|
||||||
|
border: '1px solid var(--mui-palette-primary-main, #0071c5)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gpuPods.length === 0 && (
|
||||||
|
<SectionBox title="No GPU Pods Found">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: (
|
||||||
|
<StatusLabel status="warning">
|
||||||
|
No pods requesting Intel GPU resources were found
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Note',
|
||||||
|
value:
|
||||||
|
'Pods appear here when they request resources like gpu.intel.com/i915 or gpu.intel.com/xe.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{gpuPods.length > 0 && (
|
||||||
|
<SectionBox title="Summary">
|
||||||
|
<NameValueTable
|
||||||
|
rows={[
|
||||||
|
{ name: 'Total GPU Pods', value: String(gpuPods.length) },
|
||||||
|
...(running.length > 0
|
||||||
|
? [{ name: 'Running', value: <StatusLabel status="success">{running.length}</StatusLabel> }]
|
||||||
|
: []),
|
||||||
|
...(pending.length > 0
|
||||||
|
? [{ name: 'Pending', value: <StatusLabel status="warning">{pending.length}</StatusLabel> }]
|
||||||
|
: []),
|
||||||
|
...(failed.length > 0
|
||||||
|
? [{ name: 'Failed', value: <StatusLabel status="error">{failed.length}</StatusLabel> }]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All pods table */}
|
||||||
|
{gpuPods.length > 0 && (
|
||||||
|
<SectionBox title="All GPU Pods">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||||
|
{ label: 'Namespace', getter: (p) => p.metadata.namespace ?? '—' },
|
||||||
|
{ label: 'Node', getter: (p) => p.spec?.nodeName ?? '—' },
|
||||||
|
{
|
||||||
|
label: 'Phase',
|
||||||
|
getter: (p) => (
|
||||||
|
<StatusLabel status={phaseToStatus(p.status?.phase)}>
|
||||||
|
{p.status?.phase ?? 'Unknown'}
|
||||||
|
</StatusLabel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GPU Resources',
|
||||||
|
getter: (p) => <GpuContainerList pod={p} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Restarts',
|
||||||
|
getter: (p) => {
|
||||||
|
const restarts = getPodRestarts(p);
|
||||||
|
return restarts > 0 ? (
|
||||||
|
<StatusLabel status="warning">{restarts}</StatusLabel>
|
||||||
|
) : (
|
||||||
|
String(restarts)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={gpuPods}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending pods attention box */}
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<SectionBox title="Attention: Pending GPU Pods">
|
||||||
|
<SimpleTable
|
||||||
|
columns={[
|
||||||
|
{ label: 'Name', getter: (p) => p.metadata.name },
|
||||||
|
{ label: 'Namespace', getter: (p) => p.metadata.namespace ?? '—' },
|
||||||
|
{
|
||||||
|
label: 'GPU Resources',
|
||||||
|
getter: (p) => {
|
||||||
|
const reqs = getPodGpuRequests(p);
|
||||||
|
return Object.entries(reqs)
|
||||||
|
.map(([k, v]) => `${formatGpuResourceName(k)}: ${v}`)
|
||||||
|
.join(', ') || '—';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Waiting Reason',
|
||||||
|
getter: (p) => {
|
||||||
|
const reason = p.status?.containerStatuses?.[0]?.state?.waiting?.reason;
|
||||||
|
return reason ?? '—';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ label: 'Age', getter: (p) => formatAge(p.metadata.creationTimestamp) },
|
||||||
|
]}
|
||||||
|
data={pending}
|
||||||
|
/>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* NodeColumns — adds Intel GPU columns to the native Headlamp Nodes table.
|
||||||
|
*
|
||||||
|
* Injects two columns:
|
||||||
|
* - "GPU Type" — Discrete / Integrated / — for non-GPU nodes
|
||||||
|
* - "GPU Devices" — count of i915/xe devices available on the node
|
||||||
|
*
|
||||||
|
* The processor is registered via registerResourceTableColumnsProcessor
|
||||||
|
* in index.tsx, targeting the 'headlamp-nodes' table ID.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
formatGpuType,
|
||||||
|
getNodeGpuCount,
|
||||||
|
getNodeGpuType,
|
||||||
|
isIntelGpuNode,
|
||||||
|
} from '../../api/k8s';
|
||||||
|
|
||||||
|
/** Build GPU columns to append to the native Nodes table. */
|
||||||
|
export function buildNodeGpuColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'GPU Type',
|
||||||
|
getter: (resource: unknown) => {
|
||||||
|
// resource is a Headlamp KubeObject — extract jsonData
|
||||||
|
const raw =
|
||||||
|
resource && typeof resource === 'object' && 'jsonData' in resource
|
||||||
|
? (resource as { jsonData: unknown }).jsonData
|
||||||
|
: resource;
|
||||||
|
|
||||||
|
if (!isIntelGpuNode(raw)) return '—';
|
||||||
|
const node = raw as Parameters<typeof getNodeGpuType>[0];
|
||||||
|
const type = getNodeGpuType(node);
|
||||||
|
return (
|
||||||
|
<StatusLabel status="success">
|
||||||
|
{formatGpuType(type)}
|
||||||
|
</StatusLabel>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GPU Devices',
|
||||||
|
getter: (resource: unknown) => {
|
||||||
|
const raw =
|
||||||
|
resource && typeof resource === 'object' && 'jsonData' in resource
|
||||||
|
? (resource as { jsonData: unknown }).jsonData
|
||||||
|
: resource;
|
||||||
|
|
||||||
|
if (!isIntelGpuNode(raw)) return '—';
|
||||||
|
const node = raw as Parameters<typeof getNodeGpuCount>[0];
|
||||||
|
const count = getNodeGpuCount(node);
|
||||||
|
return count > 0 ? String(count) : '—';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* headlamp-intel-gpu-plugin — entry point.
|
||||||
|
*
|
||||||
|
* Registers sidebar entries, routes, detail view sections, table column
|
||||||
|
* processors, and app bar action for Intel GPU device plugin visibility
|
||||||
|
* in Headlamp.
|
||||||
|
*
|
||||||
|
* Surfaces Intel GPU information in the following places:
|
||||||
|
* - Dedicated sidebar section: Overview / Device Plugins / Nodes / Pods
|
||||||
|
* - Native Node detail page: Intel GPU section (capacity, utilization, pods)
|
||||||
|
* - Native Pod detail page: GPU resource requests per container
|
||||||
|
* - Native Nodes table: GPU Type and GPU Devices columns
|
||||||
|
* - App bar: health badge (hidden when plugin not installed)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
registerAppBarAction,
|
||||||
|
registerDetailsViewSection,
|
||||||
|
registerResourceTableColumnsProcessor,
|
||||||
|
registerRoute,
|
||||||
|
registerSidebarEntry,
|
||||||
|
} from '@kinvolk/headlamp-plugin/lib';
|
||||||
|
import React from 'react';
|
||||||
|
import { IntelGpuDataProvider } from './api/IntelGpuDataContext';
|
||||||
|
import AppBarGpuBadge from './components/AppBarGpuBadge';
|
||||||
|
import DevicePluginsPage from './components/DevicePluginsPage';
|
||||||
|
import { buildNodeGpuColumns } from './components/integrations/NodeColumns';
|
||||||
|
import NodeDetailSection from './components/NodeDetailSection';
|
||||||
|
import NodesPage from './components/NodesPage';
|
||||||
|
import OverviewPage from './components/OverviewPage';
|
||||||
|
import PodDetailSection from './components/PodDetailSection';
|
||||||
|
import PodsPage from './components/PodsPage';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar entries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: null,
|
||||||
|
name: 'intel-gpu',
|
||||||
|
label: 'Intel GPU',
|
||||||
|
url: '/intel-gpu',
|
||||||
|
icon: 'mdi:gpu',
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: 'intel-gpu',
|
||||||
|
name: 'intel-gpu-overview',
|
||||||
|
label: 'Overview',
|
||||||
|
url: '/intel-gpu',
|
||||||
|
icon: 'mdi:view-dashboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: 'intel-gpu',
|
||||||
|
name: 'intel-gpu-device-plugins',
|
||||||
|
label: 'Device Plugins',
|
||||||
|
url: '/intel-gpu/device-plugins',
|
||||||
|
icon: 'mdi:chip',
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: 'intel-gpu',
|
||||||
|
name: 'intel-gpu-nodes',
|
||||||
|
label: 'GPU Nodes',
|
||||||
|
url: '/intel-gpu/nodes',
|
||||||
|
icon: 'mdi:server',
|
||||||
|
});
|
||||||
|
|
||||||
|
registerSidebarEntry({
|
||||||
|
parent: 'intel-gpu',
|
||||||
|
name: 'intel-gpu-pods',
|
||||||
|
label: 'GPU Pods',
|
||||||
|
url: '/intel-gpu/pods',
|
||||||
|
icon: 'mdi:cube-outline',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerRoute({
|
||||||
|
path: '/intel-gpu',
|
||||||
|
sidebar: 'intel-gpu-overview',
|
||||||
|
name: 'intel-gpu-overview',
|
||||||
|
exact: true,
|
||||||
|
component: () => (
|
||||||
|
<IntelGpuDataProvider>
|
||||||
|
<OverviewPage />
|
||||||
|
</IntelGpuDataProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
registerRoute({
|
||||||
|
path: '/intel-gpu/device-plugins',
|
||||||
|
sidebar: 'intel-gpu-device-plugins',
|
||||||
|
name: 'intel-gpu-device-plugins',
|
||||||
|
exact: true,
|
||||||
|
component: () => (
|
||||||
|
<IntelGpuDataProvider>
|
||||||
|
<DevicePluginsPage />
|
||||||
|
</IntelGpuDataProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
registerRoute({
|
||||||
|
path: '/intel-gpu/nodes',
|
||||||
|
sidebar: 'intel-gpu-nodes',
|
||||||
|
name: 'intel-gpu-nodes',
|
||||||
|
exact: true,
|
||||||
|
component: () => (
|
||||||
|
<IntelGpuDataProvider>
|
||||||
|
<NodesPage />
|
||||||
|
</IntelGpuDataProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
registerRoute({
|
||||||
|
path: '/intel-gpu/pods',
|
||||||
|
sidebar: 'intel-gpu-pods',
|
||||||
|
name: 'intel-gpu-pods',
|
||||||
|
exact: true,
|
||||||
|
component: () => (
|
||||||
|
<IntelGpuDataProvider>
|
||||||
|
<PodsPage />
|
||||||
|
</IntelGpuDataProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail view section — Node pages
|
||||||
|
// Inject Intel GPU section into native Node detail page for GPU nodes.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerDetailsViewSection(({ resource }) => {
|
||||||
|
if (resource?.kind !== 'Node') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntelGpuDataProvider>
|
||||||
|
<NodeDetailSection resource={resource} />
|
||||||
|
</IntelGpuDataProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail view section — Pod pages
|
||||||
|
// Inject Intel GPU resource section into native Pod detail page for GPU pods.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerDetailsViewSection(({ resource }) => {
|
||||||
|
if (resource?.kind !== 'Pod') return null;
|
||||||
|
return <PodDetailSection resource={resource} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table column processors — native Nodes table
|
||||||
|
// Appends GPU Type and GPU Devices columns.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerResourceTableColumnsProcessor(({ id, columns }) => {
|
||||||
|
if (id === 'headlamp-nodes') {
|
||||||
|
return [...columns, ...buildNodeGpuColumns()];
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// App bar action — Intel GPU health badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerAppBarAction(() => (
|
||||||
|
<IntelGpuDataProvider>
|
||||||
|
<AppBarGpuBadge />
|
||||||
|
</IntelGpuDataProvider>
|
||||||
|
));
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Node 22+ ships a minimal built-in `localStorage` global (property-bag only,
|
||||||
|
// no getItem/setItem/removeItem/clear) that shadows jsdom's Web Storage
|
||||||
|
// implementation. Provide a spec-compliant shim so code under test works.
|
||||||
|
if (typeof localStorage !== 'undefined' && typeof localStorage.getItem !== 'function') {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
const storage = {
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
return store.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string): void {
|
||||||
|
store.set(key, String(value));
|
||||||
|
},
|
||||||
|
removeItem(key: string): void {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
clear(): void {
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
get length(): number {
|
||||||
|
return store.size;
|
||||||
|
},
|
||||||
|
key(index: number): string | null {
|
||||||
|
return [...store.keys()][index] ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
value: storage,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: storage,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user