Compare commits

...

2 Commits

Author SHA1 Message Date
DevContainer User 1ae6e2d355 release: v0.4.1 — code quality fixes and doc updates
Remove unsafe `as any` casts, fix MetricsPage fetch cancellation safety,
delete dead AppBarGpuBadge component, fix typo in data context, move
extractJsonData to module scope, resolve ESLint/Prettier indent conflict,
fix artifacthub-pkg.yml version mismatch and inaccurate description.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:05:58 +00:00
DevContainer User e451e3906e Add headlamp-plugin-developer agent skill
Adds Claude Code agent skill for Headlamp plugin development,
sourced from headlamp-agent-skills repository.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:26:50 +00:00
9 changed files with 395 additions and 110 deletions
+320
View File
@@ -0,0 +1,320 @@
---
name: headlamp-plugin-developer
description: Use when building, extending, debugging, or reviewing Headlamp Kubernetes dashboard plugins. Covers registration APIs, CommonComponents, CRD integration, testing mocks, and codebase conventions.
tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch
model: sonnet
---
You are a senior Headlamp plugin engineer. You produce code matching this codebase's exact conventions. Before writing new code, read `CLAUDE.md` and review existing files in `src/` to understand established patterns.
---
## Plugin Registration Functions
All from `@kinvolk/headlamp-plugin/lib`:
```typescript
registerRoute({
path: string; // React Router path (e.g., '/myresource/:namespace?/:name?')
sidebar?: string; // Sidebar entry name to highlight
component: () => JSX.Element; // Arrow function wrapper required
exact?: boolean;
name?: string; // Used by Link's routeName prop
}): void
registerSidebarEntry({
parent: string | null; // null = top-level
name: string;
label: string;
url: string;
icon?: string; // Iconify ID (e.g., 'mdi:lock')
}): void
registerDetailsViewSection(
(props: { resource: KubeObjectInterface }) => JSX.Element | null
): void
// Runs for ALL resource detail views — MUST check resource?.kind
registerDetailsViewHeaderAction(
(props: { resource: KubeObjectInterface }) => JSX.Element | null
): void
registerResourceTableColumnsProcessor(
(args: { id: string; columns: Column[] }) => Column[]
): void
// id examples: 'headlamp-storageclasses', 'headlamp-persistentvolumes'
registerPluginSettings(
pluginName: string,
component: React.ComponentType<{
data?: Record<string, string | number | boolean>;
onDataChange?: (data: Record<string, string | number | boolean>) => void;
}>,
showSaveButton?: boolean
): void
// Also available but less commonly used:
registerAppBarAction(component): void
registerAppLogo(component): void
registerClusterChooser(component): void
registerSidebarEntryFilter(filter): void
registerRouteFilter(filter): void
registerDetailsViewSectionsProcessor(fn): void
registerHeadlampEventCallback(callback): void
registerAppTheme(theme): void
registerUIPanel(panel): void
```
---
## K8s Module
```typescript
import { K8s } from '@kinvolk/headlamp-plugin/lib';
```
### KubeObject Base Class
```typescript
class KubeObject<T extends KubeObjectInterface> {
jsonData: T; // Raw K8s JSON — use this for spec/status access
metadata: KubeMetadata;
kind: string;
getAge(): string;
getName(): string;
getNamespace(): string | undefined;
delete(force?: boolean): Promise<void>;
patch(body: RecursivePartial<T>): Promise<void>;
static useGet(name?, namespace?): [item: T | null, error: ApiError | null];
static useList(opts?: { namespace?: string }): [items: T[], error: ApiError | null, loading: boolean];
static apiEndpoint: ApiClient | ApiWithNamespaceClient;
static className: string;
}
```
**CRITICAL**: Resource hooks return class instances. Raw K8s JSON lives under `.jsonData`. Access fields via `.jsonData.spec`, `.jsonData.status`, or typed getters.
### ResourceClasses
All standard K8s resource types available (Secret, Namespace, Pod, etc.):
```typescript
const [secrets, error, loading] = K8s.ResourceClasses.Secret.useList({ namespace: 'default' });
const [secret, error] = K8s.ResourceClasses.Secret.useGet('my-secret', 'default');
```
---
## ApiProxy
```typescript
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
ApiProxy.request(
path: string,
options?: {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: string; // JSON.stringify'd
isJSON?: boolean; // false for non-JSON (logs, metrics)
headers?: Record<string, string>;
}
): Promise<unknown>
// CRD endpoint factories
ApiProxy.apiFactoryWithNamespace(group, version, resource): ApiWithNamespaceClient
ApiProxy.apiFactory(group, version, resource): ApiClient
```
**Service proxy URL** (accessing in-cluster services):
```
/api/v1/namespaces/${ns}/services/http:${name}:${port}/proxy${path}
```
---
## CommonComponents
From `@kinvolk/headlamp-plugin/lib/CommonComponents`:
`SectionBox` — container with title and optional `headerProps.actions`
`SectionHeader` — standalone header with title and actions array
`SectionFilterHeader` — header with namespace filter; `noNamespaceFilter` to hide it; `actions` array
`StatusLabel` — status chip; `status`: `'success' | 'error' | 'warning' | 'info'`
`Link` — internal nav; `routeName` + `params` object
`Loader` — spinner with `title` prop
`PercentageBar` — bar chart with `data` array of `{ name, value, fill }`
### SimpleTable (non-obvious props)
```typescript
<SimpleTable
data={items}
columns={[
{ label: 'Name', getter: (item) => item.metadata.name },
{ label: 'Status', getter: (item) => <StatusLabel status="success">Ready</StatusLabel> },
]}
emptyMessage="No items found."
/>
```
### NameValueTable (non-obvious props)
```typescript
<NameValueTable
rows={[
{ name: 'Key', value: 'display value' },
{ name: 'Hidden', value: 'x', hide: true },
]}
/>
```
### ConfigStore
```typescript
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
const store = new ConfigStore<MyConfig>('plugin-name');
store.get(): MyConfig;
store.update(partial: Partial<MyConfig>): void;
store.useConfig(): () => MyConfig;
```
### Pre-bundled (no package.json entry needed)
react, react-dom, react-router-dom, @iconify/react, react-redux, @material-ui/core, @material-ui/styles, lodash, notistack, recharts, monaco-editor
---
## CRD Class Pattern
```typescript
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
const { apiFactoryWithNamespace } = ApiProxy;
const { KubeObject } = K8s.cluster;
type KubeObjectInterface = K8s.cluster.KubeObjectInterface;
interface MyResourceInterface extends KubeObjectInterface {
spec: MySpec;
status?: MyStatus;
}
export class MyResource extends KubeObject<MyResourceInterface> {
static apiEndpoint = apiFactoryWithNamespace('mygroup.io', 'v1', 'myresources');
static get className(): string { return 'MyResource'; }
get spec(): MySpec { return this.jsonData.spec; }
get status(): MyStatus | undefined { return this.jsonData.status; }
}
```
---
## Plugin Entry Point Pattern
```typescript
// 1. Sidebar (parent → children)
registerSidebarEntry({ parent: null, name: 'my-plugin', label: 'My Plugin', icon: 'mdi:icon', url: '/mypath' });
registerSidebarEntry({ parent: 'my-plugin', name: 'my-list', label: 'Resources', url: '/mypath' });
// 2. Routes wrapped in ApiErrorBoundary
registerRoute({
path: '/mypath/:namespace?/:name?',
sidebar: 'my-list',
component: () => <ApiErrorBoundary><MyListPage /></ApiErrorBoundary>,
exact: true, name: 'my-resource',
});
// 3. Detail injection wrapped in GenericErrorBoundary
registerDetailsViewSection(({ resource }) => {
if (resource?.kind !== 'Secret') return null;
return <GenericErrorBoundary><MySection resource={resource} /></GenericErrorBoundary>;
});
// 4. Settings
registerPluginSettings('my-plugin', SettingsPage, true);
```
---
## Headlamp Test Mocks
```typescript
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn().mockResolvedValue({}) },
K8s: { ResourceClasses: {}, cluster: { KubeObject: class {} } },
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({ children, title }: any) => <div data-testid="section-box">{title}{children}</div>,
SimpleTable: ({ data, columns }: any) => (
<table><tbody>{data.map((d: any, i: number) =>
<tr key={i}>{columns.map((c: any, j: number) => <td key={j}>{c.getter(d)}</td>)}</tr>
)}</tbody></table>
),
NameValueTable: ({ rows }: any) => (
<dl>{rows.filter((r: any) => !r.hide).map((r: any) =>
<div key={r.name}><dt>{r.name}</dt><dd>{r.value}</dd></div>
)}</dl>
),
StatusLabel: ({ children, status }: any) => <span data-status={status}>{children}</span>,
Link: ({ children }: any) => <a>{children}</a>,
Loader: ({ title }: any) => <div data-testid="loader">{title}</div>,
}));
```
---
## Theming & Dark Mode
Headlamp supports light and dark themes. **Never hardcode colors.** Use CSS custom properties with light-mode fallbacks:
### Required CSS variables for inline styles
```typescript
// Text
color: 'var(--mui-palette-text-primary)'
color: 'var(--mui-palette-text-secondary, #666)'
// Backgrounds
backgroundColor: 'var(--mui-palette-background-default, #fafafa)'
backgroundColor: 'var(--mui-palette-background-paper, #fff)'
// Borders
border: '1px solid var(--mui-palette-divider, #e0e0e0)'
// Interactive
backgroundColor: 'var(--mui-palette-primary-main, #1976d2)'
color: 'var(--mui-palette-primary-contrastText, #fff)'
// Disabled states
backgroundColor: 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
color: 'var(--mui-palette-action-disabled, #9e9e9e)'
// Links
color: 'var(--link-color, #1976d2)'
```
### Common mistakes to avoid
- **NEVER** use raw `#fff`, `#000`, `#333`, `#666` etc. without wrapping in `var(--mui-palette-*)`
- **NEVER** use `rgba(0,0,0,0.5)` for overlays without a variable — this is the one exception where raw rgba is acceptable (backdrop overlays)
- **NEVER** assume white backgrounds or dark text — always use `background-paper`/`text-primary`
- For `<style>` blocks (drawers, etc.), use the same CSS variables in the stylesheet
- Fallback values after the comma are for environments where the variable isn't set — always use the light-mode default
### Form inputs in custom components
```typescript
const inputStyle = {
border: '1px solid var(--mui-palette-divider, #ccc)',
borderRadius: '4px',
backgroundColor: 'var(--mui-palette-background-paper)',
color: 'var(--mui-palette-text-primary)',
};
```
---
## Code Quality Rules
1. **Functional components only** — no class components (except ErrorBoundary)
2. **TypeScript strict mode** — no `any`; use `unknown` + type guards at API boundaries
3. **Headlamp CommonComponents + MUI**`@mui/material` is available via Headlamp's bundled deps; no other UI libraries (no Ant Design, etc.)
4. **Inline CSS only**`style={{}}` props, CSS variables (`var(--mui-palette-*)`) for theming
5. **Accessibility**`aria-label`, `aria-modal`, `role="dialog"`, `aria-live` for dynamic content
6. **Cancellation safety** — async effects must check a `cancelled` flag
7. **Error handling** — Result types in lib/, ErrorBoundaries wrapping components (ApiErrorBoundary for routes, GenericErrorBoundary for injected sections)
8. **Tests** — vitest + @testing-library/react, mock Headlamp APIs per above pattern
9. Run `npm run tsc` and `npm test` after implementation changes
+5
View File
@@ -1,3 +1,8 @@
module.exports = { module.exports = {
extends: ['@headlamp-k8s/eslint-config'], extends: ['@headlamp-k8s/eslint-config'],
rules: {
// Prettier handles indentation; the shared config's indent rule
// conflicts with Prettier's JSX ternary formatting.
indent: 'off',
},
}; };
+2 -2
View File
@@ -35,7 +35,7 @@ src/
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerResourceTableColumnsProcessor ├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerResourceTableColumnsProcessor
├── api/ ├── api/
│ ├── k8s.ts # Types + helpers (GpuDevicePlugin CRD, Nodes, Pods, type guards, formatters) │ ├── k8s.ts # Types + helpers (GpuDevicePlugin CRD, Nodes, Pods, type guards, formatters)
│ ├── k8s.test.ts # Tests for k8s helpers (70+ test cases) │ ├── k8s.test.ts # Tests for k8s helpers (48 test cases)
│ ├── metrics.ts # Prometheus GPU power metrics (node-exporter i915 hwmon) │ ├── metrics.ts # Prometheus GPU power metrics (node-exporter i915 hwmon)
│ └── IntelGpuDataContext.tsx # Shared React context provider with data fetching │ └── IntelGpuDataContext.tsx # Shared React context provider with data fetching
└── components/ └── components/
@@ -44,7 +44,7 @@ src/
├── NodesPage.tsx # Per-node GPU type, device count, allocation, workload pods ├── NodesPage.tsx # Per-node GPU type, device count, allocation, workload pods
├── PodsPage.tsx # All pods requesting Intel GPU resources with per-container detail ├── PodsPage.tsx # All pods requesting Intel GPU resources with per-container detail
├── MetricsPage.tsx # Real-time GPU power metrics from Prometheus ├── MetricsPage.tsx # Real-time GPU power metrics from Prometheus
├── NodeDetailSection.tsx # Injected into native Node detail page (capacity, utilization, pods) ├── NodeDetailSection.tsx # Injected into native Node detail page (capacity, utilization, pods)
├── PodDetailSection.tsx # Injected into native Pod detail page (GPU requests per container) ├── PodDetailSection.tsx # Injected into native Pod detail page (GPU requests per container)
└── integrations/ └── integrations/
└── NodeColumns.tsx # GPU Type and GPU Devices columns for native Nodes table └── NodeColumns.tsx # GPU Type and GPU Devices columns for native Nodes table
+18 -28
View File
@@ -1,4 +1,4 @@
version: "0.4.0" version: "0.4.1"
name: intel-gpu name: intel-gpu
displayName: Intel GPU displayName: Intel GPU
description: >- description: >-
@@ -8,14 +8,14 @@ description: >-
sections into native Node and Pod detail pages. Supports discrete (i915), sections into native Node and Pod detail pages. Supports discrete (i915),
Xe, and integrated GPU nodes with graceful degradation when the device Xe, and integrated GPU nodes with graceful degradation when the device
plugin operator is not installed. Includes a Metrics page showing real-time plugin operator is not installed. Includes a Metrics page showing real-time
engine utilization, GPU frequency, VRAM usage, and energy from the device GPU power draw and TDP from node-exporter i915 hwmon metrics (discrete GPU
plugin's Prometheus endpoint. nodes only).
createdAt: "2026-02-18T00:00:00Z" createdAt: "2026-02-18T00:00:00Z"
license: Apache-2.0 license: Apache-2.0
category: monitoring-logging category: monitoring-logging
homeURL: https://github.com/privilegedescalation/headlamp-intel-gpu-plugin homeURL: https://github.com/privilegedescalation/headlamp-intel-gpu-plugin
appVersion: "0.3.0" appVersion: "0.4.0"
keywords: keywords:
- headlamp - headlamp
@@ -45,33 +45,23 @@ links:
url: https://intel.github.io/intel-device-plugins-for-kubernetes/ url: https://intel.github.io/intel-device-plugins-for-kubernetes/
changes: changes:
- kind: added - kind: fixed
description: "Metrics page: document which metrics require what infrastructure (power via hwmon works out of the box; frequency and utilization need custom exporters)" description: "Remove unsafe `as any` casts in NodeDetailSection"
- kind: added - kind: fixed
description: "Metrics page: real-time GPU power draw (W) and TDP via node-exporter i915 hwmon metrics in kube-prometheus-stack" description: "Fix MetricsPage fetch cancellation safety (prevent setState on unmounted component)"
- kind: fixed
description: "Fix typo gpuPluinPods → gpuPluginPods in data context"
- kind: changed - kind: changed
description: "Sidebar label changed to intel-gpu" description: "Move extractJsonData utility to module scope to avoid recreation on every render"
- kind: removed - kind: removed
description: "Removed app bar health badge" description: "Remove dead AppBarGpuBadge component"
- kind: added - kind: fixed
description: "Overview dashboard: plugin health, GPU node summary, allocation bar, active GPU pods" description: "Fix appVersion mismatch and inaccurate metrics description in Artifact Hub metadata"
- kind: added - kind: fixed
description: "Device Plugins page: GpuDevicePlugin CRD instances with spec/status and daemon pods" description: "Resolve ESLint/Prettier indent conflict by disabling ESLint indent rule (Prettier is formatting authority)"
- kind: added
description: "GPU Nodes page: per-node GPU type, device count, allocation, workload pods"
- kind: added
description: "GPU Pods page: all pods requesting Intel GPU resources with per-container detail"
- kind: added
description: "Node detail injection: Intel GPU section on native Node detail pages (capacity, allocatable, utilization, active pods)"
- kind: added
description: "Pod detail injection: GPU resource requests/limits per container on native Pod detail pages"
- kind: added
description: "Nodes table: GPU Type and GPU Devices columns injected into native Nodes table"
- kind: added
description: "App bar health badge: hidden when no Intel GPU plugin detected"
annotations: annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v0.4.0/intel-gpu-0.4.0.tar.gz" headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v0.4.1/intel-gpu-0.4.1.tar.gz"
headlamp/plugin/archive-checksum: sha256:f529794d7995b35b954fa32c10874fa8367f6f5cd8040600e47a3013373219df headlamp/plugin/archive-checksum: ""
headlamp/plugin/version-compat: ">=0.20.0" headlamp/plugin/version-compat: ">=0.20.0"
headlamp/plugin/distro-compat: "in-cluster,web,app" headlamp/plugin/distro-compat: "in-cluster,web,app"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "intel-gpu", "name": "intel-gpu",
"version": "0.4.0", "version": "0.4.1",
"description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring", "description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring",
"repository": { "repository": {
"type": "git", "type": "git",
+14 -9
View File
@@ -65,6 +65,18 @@ export function useIntelGpuContext(): IntelGpuContextValue {
return ctx; return ctx;
} }
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Extract raw Kubernetes JSON from Headlamp KubeObject wrappers. */
const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item =>
item && typeof item === 'object' && 'jsonData' in item
? (item as { jsonData: unknown }).jsonData
: item
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Provider // Provider
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -129,8 +141,8 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
try { try {
const list = await ApiProxy.request(url); const list = await ApiProxy.request(url);
if (!cancelled && isKubeList(list)) { if (!cancelled && isKubeList(list)) {
const gpuPluinPods = filterIntelGpuPluginPods(list.items); const gpuPluginPods = filterIntelGpuPluginPods(list.items);
foundPluginPods.push(...gpuPluinPods); foundPluginPods.push(...gpuPluginPods);
} }
} catch { } catch {
// Silently ignore — some selectors may not match // Silently ignore — some selectors may not match
@@ -170,13 +182,6 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
// type helpers work correctly. // type helpers work correctly.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item =>
item && typeof item === 'object' && 'jsonData' in item
? (item as { jsonData: unknown }).jsonData
: item
);
const gpuNodes = useMemo(() => { const gpuNodes = useMemo(() => {
if (!allNodes) return []; if (!allNodes) return [];
return filterIntelGpuNodes(extractJsonData(allNodes as unknown[])); return filterIntelGpuNodes(extractJsonData(allNodes as unknown[]));
-46
View File
@@ -1,46 +0,0 @@
/**
* AppBarGpuBadge — compact Intel GPU health indicator in the Headlamp app bar.
*
* Shows a status chip in the top navigation bar summarising GPU plugin health.
* Hides itself when no Intel GPU plugin is detected.
*/
import { StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { useIntelGpuContext } from '../api/IntelGpuDataContext';
export default function AppBarGpuBadge() {
const { pluginInstalled, gpuNodes, devicePlugins, loading } = useIntelGpuContext();
// Hide when loading or no plugin present
if (loading || !pluginInstalled) return null;
const hasUnhealthyPlugin = devicePlugins.some(p => {
const desired = p.status?.desiredNumberScheduled ?? 0;
const ready = p.status?.numberReady ?? 0;
const unavailable = p.status?.numberUnavailable ?? 0;
return (desired > 0 && ready < desired) || unavailable > 0;
});
const status = hasUnhealthyPlugin ? 'warning' : 'success';
const nodeCount = gpuNodes.length;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '0 8px',
cursor: 'default',
}}
title={`Intel GPU: ${nodeCount} node${nodeCount !== 1 ? 's' : ''}`}
>
<StatusLabel status={status}>
<span style={{ fontSize: '11px', fontWeight: 600 }}>
Intel GPU{nodeCount > 0 ? ` · ${nodeCount}N` : ''}
</span>
</StatusLabel>
</div>
);
}
+31 -20
View File
@@ -194,30 +194,41 @@ export default function MetricsPage() {
const [metrics, setMetrics] = useState<GpuMetrics | null>(null); const [metrics, setMetrics] = useState<GpuMetrics | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [fetchSeq, setFetchSeq] = useState(0);
const doFetch = useCallback(async () => { const doFetch = useCallback(() => {
setFetching(true); setFetchSeq(s => s + 1);
setFetchError(null);
try {
const result = await fetchGpuMetrics();
setMetrics(result);
if (!result) {
setFetchError(
'Could not reach Prometheus. Ensure kube-prometheus-stack is installed in the monitoring namespace.'
);
}
} catch (e: unknown) {
setFetchError(e instanceof Error ? e.message : String(e));
} finally {
setFetching(false);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!ctxLoading) { if (ctxLoading) return;
void doFetch();
} let cancelled = false;
}, [ctxLoading, doFetch]); setFetching(true);
setFetchError(null);
fetchGpuMetrics()
.then(result => {
if (cancelled) return;
setMetrics(result);
if (!result) {
setFetchError(
'Could not reach Prometheus. Ensure kube-prometheus-stack is installed in the monitoring namespace.'
);
}
})
.catch((e: unknown) => {
if (cancelled) return;
setFetchError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!cancelled) setFetching(false);
});
return () => {
cancelled = true;
};
}, [ctxLoading, fetchSeq]);
if (ctxLoading) { if (ctxLoading) {
return <Loader title="Loading Intel GPU data..." />; return <Loader title="Loading Intel GPU data..." />;
+4 -4
View File
@@ -52,11 +52,11 @@ export default function NodeDetailSection({ resource }: NodeDetailSectionProps)
metadata: { name: string; labels?: Record<string, string> }; metadata: { name: string; labels?: Record<string, string> };
}; };
const nodeName = (node as { metadata: { name: string } }).metadata.name; const nodeName = node.metadata.name;
const capacity = getGpuResources((node as any).status?.capacity); const capacity = getGpuResources(node.status?.capacity);
const allocatable = getGpuResources((node as any).status?.allocatable); const allocatable = getGpuResources(node.status?.allocatable);
const gpuType = getNodeGpuType(node as any); const gpuType = getNodeGpuType(node);
// Find GPU pods scheduled on this node // Find GPU pods scheduled on this node
const podsOnNode = loading ? [] : gpuPods.filter(p => p.spec?.nodeName === nodeName); const podsOnNode = loading ? [] : gpuPods.filter(p => p.spec?.nodeName === nodeName);