Compare commits

..

4 Commits

Author SHA1 Message Date
github-actions[bot] 428575de95 release: v0.1.3 2026-03-04 12:49:57 +00:00
DevContainer User 5c57f52abc style: reformat with prettier to resolve eslint/prettier conflict
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:46:54 +00:00
DevContainer User f26d1414b2 fix: resolve bugs in ServicesPage, NodesPage, and k8s helpers
- Add missing useEffect dependency array and useCallback for closePanel
- Fix invalid StatusLabel status="" to "info" for non-kube-vip services
- Add ARIA dialog attributes to service detail panel
- Use phaseToStatus() in NodesPage instead of hardcoded Running check
- Remove dead code in getNodeVipLabel (label keys never contain =)
- Simplify redundant lease lookup in OverviewPage
- Fix 46 ESLint indentation warnings
- Add CHANGELOG.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:43:23 +00:00
DevContainer User aa676e8300 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:51 +00:00
10 changed files with 384 additions and 21 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
+40
View File
@@ -0,0 +1,40 @@
# Changelog
## [0.1.3] - 2026-03-04
### Fixed
- Fix missing `useEffect` dependency array on Escape key listener in ServicesPage (re-registered every render)
- Wrap `closePanel` in `useCallback` to stabilize effect dependencies
- Fix invalid empty string `StatusLabel` status for non-kube-vip services (now uses `"info"`)
- Add ARIA `role="dialog"`, `aria-modal`, and `aria-label` to service detail slide-in panel
- Replace invalid `aria-label` on backdrop div with `role="presentation"`
- Use `phaseToStatus()` for pod status in NodesPage instead of hardcoded check (Failed pods now correctly show error)
- Remove unreachable `startsWith('kube-vip.io/has-ip=')` branch in `getNodeVipLabel` (label keys never contain `=`)
- Simplify redundant lease lookup conditions in OverviewPage
- Fix 46 ESLint indentation warnings across all source files
## [0.1.2] - 2025-05-20
### Fixed
- Add `--allow-same-version` flag for idempotent release retries
- Use `action-gh-release` instead of `gh` CLI for release creation
## [0.1.1] - 2025-05-20
### Fixed
- Remove redundant `mv` in release workflow
- Move Node.js setup before `npm version` in release workflow
## [0.1.0] - 2025-05-20
### Added
- Initial release
- Overview dashboard with deployment status, VIP mode, leader election
- LoadBalancer services page with VIP assignments and detail panel
- Nodes page with kube-vip pod status and leader designation
- Configuration page with DaemonSet config, IP pools, leases
- Service detail section injected into native Headlamp Service views
+3 -3
View File
@@ -1,4 +1,4 @@
version: "0.1.2"
version: "0.1.3"
name: kube-vip
displayName: kube-vip
createdAt: "2026-03-04T00:00:00Z"
@@ -25,7 +25,7 @@ maintainers:
provider:
name: privilegedescalation
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-kube-vip-plugin/releases/download/v0.1.2/kube-vip-0.1.2.tar.gz"
headlamp/plugin/archive-checksum: sha256:6d75838cdb8f0a62b1e7ef3af0d114bd1b9fd2c9e8e32cd7ecb44e4ca6ef184b
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-kube-vip-plugin/releases/download/v0.1.3/kube-vip-0.1.3.tar.gz"
headlamp/plugin/archive-checksum: sha256:f57ea060c0c276c5cfdff2664265f31f69f00ad88f7c1f4a26702a2a9fbdc76e
headlamp/plugin/version-compat: ">=0.26"
headlamp/plugin/distro-compat: "in-cluster"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kube-vip",
"version": "0.1.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kube-vip",
"version": "0.1.2",
"version": "0.1.3",
"license": "Apache-2.0",
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kube-vip",
"version": "0.1.2",
"version": "0.1.3",
"description": "Headlamp plugin for kube-vip virtual IP and load balancer visibility",
"repository": {
"type": "git",
-1
View File
@@ -210,7 +210,6 @@ export function isControlPlaneNode(node: KubeVipNode): boolean {
export function getNodeVipLabel(node: KubeVipNode): string | undefined {
const labels = node.metadata.labels ?? {};
for (const [key, value] of Object.entries(labels)) {
if (key.startsWith('kube-vip.io/has-ip=')) return value;
if (key === 'kube-vip.io/has-ip') return value;
}
return undefined;
+3 -2
View File
@@ -20,6 +20,7 @@ import {
getNodeVipLabel,
isControlPlaneNode,
isNodeReady,
phaseToStatus,
} from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
@@ -83,7 +84,7 @@ export default function NodesPage() {
const pod = podByNode.get(n.metadata.name);
if (!pod) return '—';
return (
<StatusLabel status={pod.status?.phase === 'Running' ? 'success' : 'warning'}>
<StatusLabel status={phaseToStatus(pod.status?.phase)}>
{pod.status?.phase ?? 'Unknown'}
</StatusLabel>
);
@@ -127,7 +128,7 @@ export default function NodesPage() {
const pod = podByNode.get(n.metadata.name);
if (!pod) return '—';
return (
<StatusLabel status={pod.status?.phase === 'Running' ? 'success' : 'warning'}>
<StatusLabel status={phaseToStatus(pod.status?.phase)}>
{pod.status?.phase ?? 'Unknown'}
</StatusLabel>
);
+2 -6
View File
@@ -65,12 +65,8 @@ export default function OverviewPage() {
const controlPlaneVIP = kubeVipConfig['address'] ?? '—';
// Find leader from leases
const cpLease = leases.find(
l => l.metadata.name.startsWith('plndr-cp-lock') || l.metadata.name === 'plndr-cp-lock'
);
const svcLease = leases.find(
l => l.metadata.name.startsWith('plndr-svcs-lock') || l.metadata.name === 'plndr-svcs-lock'
);
const cpLease = leases.find(l => l.metadata.name.startsWith('plndr-cp-lock'));
const svcLease = leases.find(l => l.metadata.name.startsWith('plndr-svcs-lock'));
const leaderNode = cpLease?.spec?.holderIdentity ?? svcLease?.spec?.holderIdentity ?? '—';
return (
+1 -1
View File
@@ -76,7 +76,7 @@ describe('ServicesPage', () => {
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
fireEvent.click(screen.getByTestId('panel-backdrop'));
expect(mockPush).toHaveBeenCalledWith('/kube-vip/services');
});
+12 -5
View File
@@ -13,7 +13,7 @@ import {
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import {
formatAge,
@@ -38,7 +38,10 @@ export default function ServicesPage() {
? loadBalancerServices.find(s => `${s.metadata.namespace}/${s.metadata.name}` === selectedName)
: null;
const closePanel = () => history.push(location.pathname);
const closePanel = useCallback(
() => history.push(location.pathname),
[history, location.pathname]
);
useEffect(() => {
if (!selectedName) return;
@@ -47,7 +50,7 @@ export default function ServicesPage() {
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
}, [selectedName, closePanel]);
if (loading) {
return <Loader title="Loading services..." />;
@@ -111,7 +114,7 @@ export default function ServicesPage() {
{
label: 'kube-vip',
getter: s => (
<StatusLabel status={isKubeVipService(s) ? 'success' : ''}>
<StatusLabel status={isKubeVipService(s) ? 'success' : 'info'}>
{isKubeVipService(s) ? 'Yes' : '—'}
</StatusLabel>
),
@@ -130,8 +133,9 @@ export default function ServicesPage() {
{selectedService && (
<>
<div
role="presentation"
data-testid="panel-backdrop"
onClick={closePanel}
aria-label="Close panel backdrop"
style={{
position: 'fixed',
top: 0,
@@ -165,6 +169,9 @@ function ServiceDetailPanel({
return (
<div
role="dialog"
aria-modal="true"
aria-label="Service Details"
style={{
position: 'fixed',
top: 0,