feat: initial kube-vip Headlamp plugin

Headlamp plugin providing visibility into kube-vip virtual IP and load
balancer deployments. Features:

- Overview dashboard with deployment status, VIP mode, leader election
- Services page with LoadBalancer VIP assignments and detail panels
- Nodes page showing kube-vip pod status and leader designation
- Configuration page with DaemonSet config, IP pools, leases
- Service detail section injected into native Headlamp Service views

Read-only plugin — no cluster write operations. Uses standard K8s
resources (no CRDs): Services, Nodes, Pods, DaemonSets, Leases,
ConfigMaps with kube-vip.io/* annotations.

74 tests across 7 test files. All tsc/lint/format/test checks pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DevContainer User
2026-03-04 00:23:08 +00:00
commit 3b9d007e8b
37 changed files with 22722 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
// Mock headlamp plugin APIs before importing the module under test
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: {
request: vi.fn().mockResolvedValue({ items: [] }),
},
K8s: {
ResourceClasses: {
Service: {
useList: vi.fn(() => [[], null]),
},
Node: {
useList: vi.fn(() => [[], null]),
},
},
},
}));
import { KubeVipDataProvider, useKubeVipContext } from './KubeVipDataContext';
describe('useKubeVipContext', () => {
it('throws when used outside KubeVipDataProvider', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useKubeVipContext());
}).toThrow('useKubeVipContext must be used within a KubeVipDataProvider');
spy.mockRestore();
});
it('returns context value when inside KubeVipDataProvider', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KubeVipDataProvider>{children}</KubeVipDataProvider>
);
const { result } = renderHook(() => useKubeVipContext(), { wrapper });
expect(result.current).toBeDefined();
expect(result.current.loadBalancerServices).toBeInstanceOf(Array);
expect(result.current.nodes).toBeInstanceOf(Array);
expect(result.current.kubeVipPods).toBeInstanceOf(Array);
expect(result.current.cloudProviderPods).toBeInstanceOf(Array);
expect(result.current.leases).toBeInstanceOf(Array);
expect(result.current.ipPools).toBeInstanceOf(Array);
expect(typeof result.current.refresh).toBe('function');
});
});
+290
View File
@@ -0,0 +1,290 @@
/**
* KubeVipDataContext — shared data provider for kube-vip Kubernetes resources.
*
* Fetches Services (LoadBalancer), Nodes, kube-vip DaemonSet/pods,
* Leases, and the kubevip ConfigMap. Provides filtered data to all
* child pages via React context.
*/
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
DaemonSetStatus,
filterLoadBalancerServices,
IPPool,
isKubeList,
KUBE_VIP_CLOUD_PROVIDER_SELECTOR,
KUBE_VIP_CONFIGMAP_NAME,
KUBE_VIP_DAEMONSET_NAME,
KUBE_VIP_NAMESPACE,
KUBE_VIP_POD_SELECTOR,
KubeVipConfigMap,
KubeVipDaemonSet,
KubeVipLease,
KubeVipNode,
KubeVipPod,
KubeVipService,
parseIPPools,
} from './k8s';
// ---------------------------------------------------------------------------
// Context shape
// ---------------------------------------------------------------------------
export interface KubeVipContextValue {
// kube-vip deployment
kubeVipInstalled: boolean;
daemonSetStatus: DaemonSetStatus | null;
kubeVipPods: KubeVipPod[];
cloudProviderPods: KubeVipPod[];
// Services managed by kube-vip (type: LoadBalancer)
loadBalancerServices: KubeVipService[];
// Nodes
nodes: KubeVipNode[];
// Leader election
leases: KubeVipLease[];
// IP pool configuration
ipPools: IPPool[];
configMapData: Record<string, string>;
// kube-vip configuration (from DaemonSet env vars)
kubeVipConfig: Record<string, string>;
// Loading / error state
loading: boolean;
error: string | null;
// Manual refresh trigger
refresh: () => void;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const KubeVipContext = createContext<KubeVipContextValue | null>(null);
export function useKubeVipContext(): KubeVipContextValue {
const ctx = useContext(KubeVipContext);
if (!ctx) {
throw new Error('useKubeVipContext must be used within a KubeVipDataProvider');
}
return ctx;
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function KubeVipDataProvider({ children }: { children: React.ReactNode }) {
// Async-fetched resources
const [kubeVipPods, setKubeVipPods] = useState<KubeVipPod[]>([]);
const [cloudProviderPods, setCloudProviderPods] = useState<KubeVipPod[]>([]);
const [daemonSetStatus, setDaemonSetStatus] = useState<DaemonSetStatus | null>(null);
const [leases, setLeases] = useState<KubeVipLease[]>([]);
const [configMapData, setConfigMapData] = useState<Record<string, string>>({});
const [kubeVipConfig, setKubeVipConfig] = useState<Record<string, string>>({});
const [asyncLoading, setAsyncLoading] = useState(true);
const [asyncError, setAsyncError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
// K8s resource hooks — Headlamp re-fetches on cluster changes automatically
const [allServices, svcError] = K8s.ResourceClasses.Service.useList({ namespace: '' });
const [allNodes, nodeError] = K8s.ResourceClasses.Node.useList();
const refresh = useCallback(() => {
setRefreshKey(k => k + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function fetchAsync() {
setAsyncLoading(true);
setAsyncError(null);
try {
// kube-vip DaemonSet
try {
const ds = (await ApiProxy.request(
`/apis/apps/v1/namespaces/${KUBE_VIP_NAMESPACE}/daemonsets/${KUBE_VIP_DAEMONSET_NAME}`
)) as KubeVipDaemonSet;
if (!cancelled) {
setDaemonSetStatus(ds.status ?? null);
// Extract config from DaemonSet template env vars
const env = ds.spec?.template?.spec?.containers?.[0]?.env;
if (env) {
const config: Record<string, string> = {};
for (const e of env) {
if (e.value !== undefined) config[e.name] = e.value;
}
setKubeVipConfig(config);
}
}
} catch {
if (!cancelled) {
setDaemonSetStatus(null);
setKubeVipConfig({});
}
}
// kube-vip pods
try {
const podList = await ApiProxy.request(
`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
KUBE_VIP_POD_SELECTOR
)}`
);
if (!cancelled && isKubeList(podList)) {
setKubeVipPods(podList.items as KubeVipPod[]);
}
} catch {
// If label selector doesn't match, try listing all pods in kube-system
// and filtering by name prefix (for static pod deployments)
try {
const allPods = await ApiProxy.request(`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/pods`);
if (!cancelled && isKubeList(allPods)) {
const kvPods = (allPods.items as KubeVipPod[]).filter(p =>
p.metadata.name.startsWith('kube-vip')
);
setKubeVipPods(kvPods);
}
} catch {
if (!cancelled) setKubeVipPods([]);
}
}
// kube-vip-cloud-provider pods
try {
const cpList = await ApiProxy.request(
`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/pods?labelSelector=${encodeURIComponent(
KUBE_VIP_CLOUD_PROVIDER_SELECTOR
)}`
);
if (!cancelled && isKubeList(cpList)) {
setCloudProviderPods(cpList.items as KubeVipPod[]);
}
} catch {
if (!cancelled) setCloudProviderPods([]);
}
// Leases (kube-vip uses leases for leader election)
try {
const leaseList = await ApiProxy.request(
`/apis/coordination.k8s.io/v1/namespaces/${KUBE_VIP_NAMESPACE}/leases`
);
if (!cancelled && isKubeList(leaseList)) {
const kvLeases = (leaseList.items as KubeVipLease[]).filter(
l => l.metadata.name.startsWith('plndr-') || l.metadata.name.startsWith('kube-vip-')
);
setLeases(kvLeases);
}
} catch {
if (!cancelled) setLeases([]);
}
// kubevip ConfigMap (IP pool configuration)
try {
const cm = (await ApiProxy.request(
`/api/v1/namespaces/${KUBE_VIP_NAMESPACE}/configmaps/${KUBE_VIP_CONFIGMAP_NAME}`
)) as KubeVipConfigMap;
if (!cancelled) {
setConfigMapData(cm.data ?? {});
}
} catch {
if (!cancelled) setConfigMapData({});
}
} 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
// ---------------------------------------------------------------------------
const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item =>
item && typeof item === 'object' && 'jsonData' in item
? (item as { jsonData: unknown }).jsonData
: item
);
const loadBalancerServices = useMemo(() => {
if (!allServices) return [];
return filterLoadBalancerServices(extractJsonData(allServices as unknown[]));
}, [allServices]);
const nodes = useMemo(() => {
if (!allNodes) return [];
return extractJsonData(allNodes as unknown[]) as KubeVipNode[];
}, [allNodes]);
const ipPools = useMemo(() => parseIPPools(configMapData), [configMapData]);
// ---------------------------------------------------------------------------
// Combined loading / error state
// ---------------------------------------------------------------------------
const loading = asyncLoading || !allServices || !allNodes;
const errors: string[] = [];
if (svcError) errors.push(String(svcError));
if (nodeError) errors.push(String(nodeError));
if (asyncError) errors.push(asyncError);
const error = errors.length > 0 ? errors.join('; ') : null;
const kubeVipInstalled = kubeVipPods.length > 0 || daemonSetStatus !== null;
// ---------------------------------------------------------------------------
// Memoized context value
// ---------------------------------------------------------------------------
const value = useMemo<KubeVipContextValue>(
() => ({
kubeVipInstalled,
daemonSetStatus,
kubeVipPods,
cloudProviderPods,
loadBalancerServices,
nodes,
leases,
ipPools,
configMapData,
kubeVipConfig,
loading,
error,
refresh,
}),
[
kubeVipInstalled,
daemonSetStatus,
kubeVipPods,
cloudProviderPods,
loadBalancerServices,
nodes,
leases,
ipPools,
configMapData,
kubeVipConfig,
loading,
error,
refresh,
]
);
return <KubeVipContext.Provider value={value}>{children}</KubeVipContext.Provider>;
}
+292
View File
@@ -0,0 +1,292 @@
import { describe, expect, it } from 'vitest';
import {
extractPodConfig,
filterLoadBalancerServices,
formatAge,
getNodeInternalIP,
getNodeVipLabel,
getPodImage,
getPodRestarts,
getServiceVIPs,
getVipHost,
isControlPlaneNode,
isEgressEnabled,
isKubeList,
isKubeVipService,
isLoadBalancerService,
isNodeReady,
isPodReady,
isServiceIgnored,
parseIPPools,
phaseToStatus,
} from './k8s';
describe('isKubeList', () => {
it('returns true for objects with items array', () => {
expect(isKubeList({ items: [] })).toBe(true);
expect(isKubeList({ items: [1, 2] })).toBe(true);
});
it('returns false for non-objects and missing items', () => {
expect(isKubeList(null)).toBe(false);
expect(isKubeList(undefined)).toBe(false);
expect(isKubeList('string')).toBe(false);
expect(isKubeList({ data: [] })).toBe(false);
});
});
describe('isLoadBalancerService', () => {
it('identifies LoadBalancer services', () => {
expect(isLoadBalancerService({ spec: { type: 'LoadBalancer' }, metadata: { name: 'x' } })).toBe(
true
);
expect(isLoadBalancerService({ spec: { type: 'ClusterIP' }, metadata: { name: 'x' } })).toBe(
false
);
expect(isLoadBalancerService(null)).toBe(false);
});
});
describe('isKubeVipService', () => {
it('returns true when kube-vip annotations are present', () => {
expect(
isKubeVipService({
metadata: { name: 'x', annotations: { 'kube-vip.io/loadbalancerIPs': '1.2.3.4' } },
spec: { type: 'LoadBalancer' },
})
).toBe(true);
});
it('returns false when no kube-vip annotations', () => {
expect(
isKubeVipService({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer' },
})
).toBe(false);
});
});
describe('getServiceVIPs', () => {
it('returns IPs from annotation', () => {
const vips = getServiceVIPs({
metadata: { name: 'x', annotations: { 'kube-vip.io/loadbalancerIPs': '1.2.3.4,5.6.7.8' } },
spec: { type: 'LoadBalancer' },
});
expect(vips).toEqual(['1.2.3.4', '5.6.7.8']);
});
it('falls back to status.loadBalancer.ingress', () => {
const vips = getServiceVIPs({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer' },
status: { loadBalancer: { ingress: [{ ip: '10.0.0.1' }] } },
});
expect(vips).toEqual(['10.0.0.1']);
});
it('falls back to spec.loadBalancerIP', () => {
const vips = getServiceVIPs({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer', loadBalancerIP: '10.0.0.2' },
});
expect(vips).toEqual(['10.0.0.2']);
});
it('returns empty array when no VIP info', () => {
const vips = getServiceVIPs({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer' },
});
expect(vips).toEqual([]);
});
});
describe('getVipHost', () => {
it('returns the vipHost annotation value', () => {
expect(
getVipHost({
metadata: { name: 'x', annotations: { 'kube-vip.io/vipHost': 'node-1' } },
spec: { type: 'LoadBalancer' },
})
).toBe('node-1');
});
it('returns undefined when not present', () => {
expect(
getVipHost({
metadata: { name: 'x' },
spec: { type: 'LoadBalancer' },
})
).toBeUndefined();
});
});
describe('isEgressEnabled / isServiceIgnored', () => {
it('detects egress enabled', () => {
expect(
isEgressEnabled({
metadata: { name: 'x', annotations: { 'kube-vip.io/egress': 'true' } },
spec: { type: 'LoadBalancer' },
})
).toBe(true);
});
it('detects ignored service', () => {
expect(
isServiceIgnored({
metadata: { name: 'x', annotations: { 'kube-vip.io/ignore': 'true' } },
spec: { type: 'LoadBalancer' },
})
).toBe(true);
});
});
describe('filterLoadBalancerServices', () => {
it('filters only LoadBalancer services', () => {
const items = [
{ spec: { type: 'LoadBalancer' }, metadata: { name: 'a' } },
{ spec: { type: 'ClusterIP' }, metadata: { name: 'b' } },
{ spec: { type: 'LoadBalancer' }, metadata: { name: 'c' } },
];
expect(filterLoadBalancerServices(items)).toHaveLength(2);
});
});
describe('Node helpers', () => {
const node = {
metadata: {
name: 'node-1',
labels: { 'node-role.kubernetes.io/control-plane': '' },
},
status: {
conditions: [{ type: 'Ready', status: 'True' }],
addresses: [{ type: 'InternalIP', address: '10.0.0.1' }],
},
};
it('isNodeReady returns true for Ready node', () => {
expect(isNodeReady(node)).toBe(true);
});
it('isControlPlaneNode returns true for control-plane labeled node', () => {
expect(isControlPlaneNode(node)).toBe(true);
});
it('getNodeInternalIP returns the InternalIP', () => {
expect(getNodeInternalIP(node)).toBe('10.0.0.1');
});
it('getNodeVipLabel returns undefined when no VIP label', () => {
expect(getNodeVipLabel(node)).toBeUndefined();
});
});
describe('Pod helpers', () => {
const pod = {
metadata: { name: 'kube-vip-ds-abc' },
spec: {
containers: [
{
name: 'kube-vip',
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
env: [
{ name: 'address', value: '192.168.1.100' },
{ name: 'vip_arp', value: 'true' },
],
},
],
},
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{
name: 'kube-vip',
ready: true,
restartCount: 2,
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
},
],
},
};
it('isPodReady returns true for Ready pod', () => {
expect(isPodReady(pod)).toBe(true);
});
it('getPodRestarts sums container restarts', () => {
expect(getPodRestarts(pod)).toBe(2);
});
it('getPodImage returns container image', () => {
expect(getPodImage(pod)).toBe('ghcr.io/kube-vip/kube-vip:v0.8.0');
});
it('extractPodConfig extracts env vars', () => {
const config = extractPodConfig(pod);
expect(config).toEqual({ address: '192.168.1.100', vip_arp: 'true' });
});
});
describe('parseIPPools', () => {
it('parses range and cidr pools', () => {
const data = {
'range-global': '192.168.1.200-192.168.1.250',
'cidr-default': '10.0.0.0/24',
};
const pools = parseIPPools(data);
expect(pools).toHaveLength(2);
expect(pools[0]).toEqual({
name: 'range-global',
type: 'range',
value: '192.168.1.200-192.168.1.250',
scope: 'global',
});
expect(pools[1]).toEqual({
name: 'cidr-default',
type: 'cidr',
value: '10.0.0.0/24',
scope: 'global',
});
});
it('parses namespace-scoped pools', () => {
const data = {
'staging/range-pool1': '10.1.0.100-10.1.0.200',
};
const pools = parseIPPools(data);
expect(pools).toHaveLength(1);
expect(pools[0].scope).toBe('namespace');
expect(pools[0].namespace).toBe('staging');
});
it('returns empty for undefined data', () => {
expect(parseIPPools(undefined)).toEqual([]);
});
});
describe('formatAge', () => {
it('returns "unknown" for undefined', () => {
expect(formatAge(undefined)).toBe('unknown');
});
it('formats days', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
expect(formatAge(twoDaysAgo)).toBe('2d');
});
});
describe('phaseToStatus', () => {
it('maps Running to success', () => {
expect(phaseToStatus('Running')).toBe('success');
});
it('maps Pending to warning', () => {
expect(phaseToStatus('Pending')).toBe('warning');
});
it('maps unknown to error', () => {
expect(phaseToStatus('Failed')).toBe('error');
});
});
+406
View File
@@ -0,0 +1,406 @@
/**
* Kubernetes type definitions and helper functions for kube-vip resources.
*
* kube-vip uses no CRDs — all state is in standard Kubernetes resources
* (DaemonSets, Pods, Services, Nodes, Leases, ConfigMaps).
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const KUBE_VIP_NAMESPACE = 'kube-system' as const;
export const KUBE_VIP_DAEMONSET_NAME = 'kube-vip-ds' as const;
export const KUBE_VIP_CLOUD_PROVIDER_NAME = 'kube-vip-cloud-provider' as const;
export const KUBE_VIP_CONFIGMAP_NAME = 'kubevip' as const;
export const KUBE_VIP_ANNOTATION_PREFIX = 'kube-vip.io/' as const;
export const KUBE_VIP_METRICS_PORT = 2112 as const;
/** Label selectors for kube-vip pods. */
export const KUBE_VIP_POD_SELECTOR = 'app.kubernetes.io/name=kube-vip-ds';
export const KUBE_VIP_CLOUD_PROVIDER_SELECTOR = 'app=kube-vip-cloud-provider';
// ---------------------------------------------------------------------------
// Annotation keys
// ---------------------------------------------------------------------------
export const ANNOTATION_LOADBALANCER_IPS = 'kube-vip.io/loadbalancerIPs';
export const ANNOTATION_IGNORE = 'kube-vip.io/ignore';
export const ANNOTATION_VIP_HOST = 'kube-vip.io/vipHost';
export const ANNOTATION_EGRESS = 'kube-vip.io/egress';
export const ANNOTATION_SERVICE_INTERFACE = 'kube-vip.io/serviceInterface';
export const ANNOTATION_HOSTNAME = 'kube-vip.io/loadbalancerHostname';
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// 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']);
}
// ---------------------------------------------------------------------------
// Service (LoadBalancer)
// ---------------------------------------------------------------------------
export interface ServicePort {
name?: string;
protocol?: string;
port: number;
targetPort?: number | string;
nodePort?: number;
}
export interface ServiceSpec {
type?: string;
clusterIP?: string;
externalTrafficPolicy?: string;
loadBalancerIP?: string;
ports?: ServicePort[];
selector?: Record<string, string>;
}
export interface ServiceStatus {
loadBalancer?: {
ingress?: Array<{ ip?: string; hostname?: string }>;
};
}
export interface KubeVipService extends KubeObject {
spec: ServiceSpec;
status?: ServiceStatus;
}
/** Returns true if a Service is of type LoadBalancer. */
export function isLoadBalancerService(svc: unknown): svc is KubeVipService {
if (!svc || typeof svc !== 'object') return false;
const obj = svc as Record<string, unknown>;
const spec = obj['spec'] as Record<string, unknown> | undefined;
return spec?.['type'] === 'LoadBalancer';
}
/** Returns true if a LoadBalancer service has a kube-vip annotation. */
export function isKubeVipService(svc: KubeVipService): boolean {
const annotations = svc.metadata.annotations ?? {};
return Object.keys(annotations).some(key => key.startsWith(KUBE_VIP_ANNOTATION_PREFIX));
}
/** Get the VIP address(es) from a service. */
export function getServiceVIPs(svc: KubeVipService): string[] {
// Check kube-vip annotation first
const annotatedIPs = svc.metadata.annotations?.[ANNOTATION_LOADBALANCER_IPS];
if (annotatedIPs) return annotatedIPs.split(',').map(ip => ip.trim());
// Fall back to status.loadBalancer.ingress
const ingress = svc.status?.loadBalancer?.ingress;
if (ingress && ingress.length > 0) {
return ingress.map(i => i.ip ?? i.hostname ?? '').filter(Boolean);
}
// Fall back to spec.loadBalancerIP
if (svc.spec.loadBalancerIP) return [svc.spec.loadBalancerIP];
return [];
}
/** Get the node currently hosting the VIP for this service. */
export function getVipHost(svc: KubeVipService): string | undefined {
return svc.metadata.annotations?.[ANNOTATION_VIP_HOST];
}
/** Check if egress is enabled on this service. */
export function isEgressEnabled(svc: KubeVipService): boolean {
return svc.metadata.annotations?.[ANNOTATION_EGRESS] === 'true';
}
/** Check if service is ignored by kube-vip. */
export function isServiceIgnored(svc: KubeVipService): boolean {
return svc.metadata.annotations?.[ANNOTATION_IGNORE] === 'true';
}
/** Filter LoadBalancer services from a list of unknown objects. */
export function filterLoadBalancerServices(items: unknown[]): KubeVipService[] {
return items.filter(isLoadBalancerService);
}
// ---------------------------------------------------------------------------
// Node
// ---------------------------------------------------------------------------
export interface NodeAddress {
type: string;
address: string;
}
export interface NodeCondition {
type: string;
status: string;
reason?: string;
message?: string;
lastTransitionTime?: string;
}
export interface NodeStatus {
conditions?: NodeCondition[];
addresses?: NodeAddress[];
nodeInfo?: {
kubeletVersion?: string;
osImage?: string;
containerRuntimeVersion?: string;
architecture?: string;
};
allocatable?: Record<string, string>;
capacity?: Record<string, string>;
}
export interface NodeSpec {
podCIDR?: string;
taints?: Array<{ key: string; effect: string; value?: string }>;
}
export interface KubeVipNode extends KubeObject {
spec?: NodeSpec;
status?: NodeStatus;
}
/** Check if a node is Ready. */
export function isNodeReady(node: KubeVipNode): boolean {
return node.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
}
/** Get the InternalIP of a node. */
export function getNodeInternalIP(node: KubeVipNode): string {
return node.status?.addresses?.find(a => a.type === 'InternalIP')?.address ?? '—';
}
/** Check if a node is a control plane node. */
export function isControlPlaneNode(node: KubeVipNode): boolean {
const labels = node.metadata.labels ?? {};
return (
'node-role.kubernetes.io/control-plane' in labels || 'node-role.kubernetes.io/master' in labels
);
}
/** Get kube-vip VIP label from a node (if node labeling is enabled). */
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;
}
// ---------------------------------------------------------------------------
// Pod
// ---------------------------------------------------------------------------
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 PodStatus {
phase?: string;
conditions?: Array<{ type: string; status: string }>;
containerStatuses?: ContainerStatus[];
hostIP?: string;
podIP?: string;
}
export interface PodSpec {
nodeName?: string;
hostNetwork?: boolean;
containers?: Array<{
name: string;
image?: string;
env?: Array<{ name: string; value?: string }>;
args?: string[];
}>;
}
export interface KubeVipPod extends KubeObject {
spec?: PodSpec;
status?: PodStatus;
}
/** Check if a pod is Ready. */
export function isPodReady(pod: KubeVipPod): boolean {
return pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
}
/** Get total restarts for a pod. */
export function getPodRestarts(pod: KubeVipPod): number {
return pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0;
}
/** Get the container image for a pod. */
export function getPodImage(pod: KubeVipPod): string {
return pod.spec?.containers?.[0]?.image ?? pod.status?.containerStatuses?.[0]?.image ?? 'unknown';
}
/** Extract kube-vip configuration from pod environment variables. */
export function extractPodConfig(pod: KubeVipPod): Record<string, string> {
const config: Record<string, string> = {};
const env = pod.spec?.containers?.[0]?.env;
if (!env) return config;
for (const e of env) {
if (e.value !== undefined) {
config[e.name] = e.value;
}
}
return config;
}
// ---------------------------------------------------------------------------
// DaemonSet
// ---------------------------------------------------------------------------
export interface DaemonSetStatus {
currentNumberScheduled?: number;
desiredNumberScheduled?: number;
numberReady?: number;
numberAvailable?: number;
numberMisscheduled?: number;
updatedNumberScheduled?: number;
}
export interface DaemonSetSpec {
selector?: { matchLabels?: Record<string, string> };
template?: {
spec?: PodSpec;
};
}
export interface KubeVipDaemonSet extends KubeObject {
spec?: DaemonSetSpec;
status?: DaemonSetStatus;
}
// ---------------------------------------------------------------------------
// Lease (leader election)
// ---------------------------------------------------------------------------
export interface LeaseSpec {
holderIdentity?: string;
leaseDurationSeconds?: number;
acquireTime?: string;
renewTime?: string;
leaseTransitions?: number;
}
export interface KubeVipLease extends KubeObject {
spec?: LeaseSpec;
}
// ---------------------------------------------------------------------------
// ConfigMap (IP pool configuration for kube-vip-cloud-provider)
// ---------------------------------------------------------------------------
export interface KubeVipConfigMap extends KubeObject {
data?: Record<string, string>;
}
/** Parse IP pool ranges from the kubevip ConfigMap data. */
export function parseIPPools(data: Record<string, string> | undefined): IPPool[] {
if (!data) return [];
const pools: IPPool[] = [];
for (const [key, value] of Object.entries(data)) {
if (key.startsWith('range-') || key.startsWith('cidr-')) {
pools.push({
name: key,
type: key.startsWith('range-') ? 'range' : 'cidr',
value,
scope: 'global',
});
} else if (key.includes('/')) {
// Namespace-specific pool: "namespace/range-name" or "namespace/cidr-name"
const [ns, poolName] = key.split('/', 2);
const type = poolName.startsWith('range-')
? 'range'
: poolName.startsWith('cidr-')
? 'cidr'
: 'unknown';
pools.push({
name: poolName,
type,
value,
scope: 'namespace',
namespace: ns,
});
}
}
return pools;
}
export interface IPPool {
name: string;
type: 'range' | 'cidr' | 'unknown';
value: string;
scope: 'global' | 'namespace';
namespace?: string;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
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`;
}
export function phaseToStatus(phase: string | undefined): 'success' | 'warning' | 'error' {
switch (phase) {
case 'Running':
case 'Active':
case 'Ready':
case 'Bound':
return 'success';
case 'Pending':
case 'Terminating':
return 'warning';
default:
return 'error';
}
}
+113
View File
@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import {
defaultContext,
makeSampleDaemonSetStatus,
makeSampleLease,
makeSamplePod,
} from '../test-helpers';
import ConfigPage from './ConfigPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('ConfigPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<ConfigPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading configuration...');
});
it('shows error state', () => {
mockContext({ error: 'config error' });
render(<ConfigPage />);
expect(screen.getByText('config error')).toBeInTheDocument();
});
it('shows not installed message when kube-vip is absent', () => {
mockContext({ kubeVipInstalled: false });
render(<ConfigPage />);
expect(screen.getByText(/not installed/)).toBeInTheDocument();
});
it('renders DaemonSet status when available', () => {
mockContext({
kubeVipInstalled: true,
daemonSetStatus: makeSampleDaemonSetStatus(),
});
render(<ConfigPage />);
expect(screen.getByText('DaemonSet Status')).toBeInTheDocument();
});
it('renders kube-vip configuration from env vars', () => {
mockContext({
kubeVipInstalled: true,
kubeVipConfig: {
address: '192.168.1.100',
vip_arp: 'true',
cp_enable: 'true',
svc_enable: 'false',
vip_interface: 'eth0',
},
});
render(<ConfigPage />);
expect(screen.getByText('kube-vip Configuration')).toBeInTheDocument();
expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
expect(screen.getByText('eth0')).toBeInTheDocument();
});
it('renders IP pools table', () => {
mockContext({
kubeVipInstalled: true,
ipPools: [
{ name: 'range-global', type: 'range', value: '10.0.0.100-10.0.0.200', scope: 'global' },
],
configMapData: { 'range-global': '10.0.0.100-10.0.0.200' },
});
render(<ConfigPage />);
expect(screen.getByText('IP Address Pools')).toBeInTheDocument();
expect(screen.getByText('range-global')).toBeInTheDocument();
});
it('shows no IP pools message when empty', () => {
mockContext({
kubeVipInstalled: true,
ipPools: [],
configMapData: {},
});
render(<ConfigPage />);
expect(screen.getByText(/No kubevip ConfigMap found/)).toBeInTheDocument();
});
it('renders leader election leases', () => {
const lease = makeSampleLease();
mockContext({
kubeVipInstalled: true,
leases: [lease],
});
render(<ConfigPage />);
expect(screen.getByText('Leader Election Leases')).toBeInTheDocument();
expect(screen.getByText('plndr-cp-lock')).toBeInTheDocument();
});
it('renders kube-vip pods section', () => {
const pod = makeSamplePod();
mockContext({
kubeVipInstalled: true,
kubeVipPods: [pod],
});
render(<ConfigPage />);
expect(screen.getByText('kube-vip Pods')).toBeInTheDocument();
expect(screen.getByText('kube-vip-ds-abc12')).toBeInTheDocument();
});
});
+246
View File
@@ -0,0 +1,246 @@
/**
* ConfigPage — kube-vip configuration and IP pool management.
*
* Shows: kube-vip DaemonSet configuration (from env vars), IP pool
* assignments from the kubevip ConfigMap, and leader election leases.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { formatAge, getPodImage, isPodReady, phaseToStatus } from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
/** Display-friendly names for kube-vip environment variables. */
const ENV_LABELS: Record<string, string> = {
address: 'VIP Address',
port: 'VIP Port',
vip_arp: 'ARP Mode',
bgp_enable: 'BGP Mode',
vip_interface: 'Interface',
vip_leaderelection: 'Leader Election',
vip_leaseduration: 'Lease Duration (s)',
vip_renewdeadline: 'Renew Deadline (s)',
vip_retryperiod: 'Retry Period (s)',
cp_enable: 'Control Plane HA',
svc_enable: 'Service LB',
svc_election: 'Per-Service Election',
lb_enable: 'IPVS Load Balancer',
lb_port: 'LB Port',
lb_fwdmethod: 'LB Forwarding Method',
vip_servicesinterface: 'Services Interface',
bgp_routerid: 'BGP Router ID',
bgp_as: 'BGP Local AS',
bgp_peeraddress: 'BGP Peer Address',
bgp_peeras: 'BGP Peer AS',
bgp_peers: 'BGP Peers',
prometheus_server: 'Prometheus Server',
vip_loglevel: 'Log Level',
enable_node_labeling: 'Node Labeling',
vip_subnet: 'VIP Subnet',
};
export default function ConfigPage() {
const {
kubeVipInstalled,
kubeVipConfig,
kubeVipPods,
cloudProviderPods,
daemonSetStatus,
ipPools,
configMapData,
leases,
loading,
error,
} = useKubeVipContext();
if (loading) {
return <Loader title="Loading configuration..." />;
}
if (error) {
return (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
);
}
if (!kubeVipInstalled) {
return (
<>
<SectionHeader title="kube-vip — Configuration" />
<SectionBox>
<p>kube-vip is not installed. No configuration available.</p>
</SectionBox>
</>
);
}
// Build config rows from known env vars, in a defined order
const configKeys = Object.keys(ENV_LABELS);
const knownConfigRows = configKeys
.filter(key => kubeVipConfig[key] !== undefined)
.map(key => ({
name: ENV_LABELS[key],
value:
kubeVipConfig[key] === 'true' ? (
<StatusLabel status="success">Enabled</StatusLabel>
) : kubeVipConfig[key] === 'false' ? (
'Disabled'
) : (
kubeVipConfig[key]
),
}));
// Extra env vars not in our known list
const extraConfigRows = Object.entries(kubeVipConfig)
.filter(([key]) => !configKeys.includes(key))
.map(([key, value]) => ({ name: key, value }));
return (
<>
<SectionHeader title="kube-vip — Configuration" />
{/* DaemonSet status */}
{daemonSetStatus && (
<SectionBox title="DaemonSet Status">
<NameValueTable
rows={[
{ name: 'Desired', value: String(daemonSetStatus.desiredNumberScheduled ?? 0) },
{ name: 'Current', value: String(daemonSetStatus.currentNumberScheduled ?? 0) },
{ name: 'Ready', value: String(daemonSetStatus.numberReady ?? 0) },
{ name: 'Available', value: String(daemonSetStatus.numberAvailable ?? 0) },
{ name: 'Updated', value: String(daemonSetStatus.updatedNumberScheduled ?? 0) },
...(daemonSetStatus.numberMisscheduled
? [
{
name: 'Misscheduled',
value: (
<StatusLabel status="warning">
{daemonSetStatus.numberMisscheduled}
</StatusLabel>
),
},
]
: []),
]}
/>
</SectionBox>
)}
{/* kube-vip configuration from env vars */}
{knownConfigRows.length > 0 && (
<SectionBox title="kube-vip Configuration">
<NameValueTable rows={knownConfigRows} />
</SectionBox>
)}
{extraConfigRows.length > 0 && (
<SectionBox title="Additional Environment Variables">
<NameValueTable rows={extraConfigRows} />
</SectionBox>
)}
{/* IP Pools from ConfigMap */}
{ipPools.length > 0 && (
<SectionBox title="IP Address Pools">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.name },
{ label: 'Type', getter: p => p.type.toUpperCase() },
{ label: 'Value', getter: p => p.value },
{
label: 'Scope',
getter: p => (p.scope === 'namespace' ? p.namespace ?? '—' : 'Global'),
},
]}
data={ipPools}
/>
</SectionBox>
)}
{Object.keys(configMapData).length === 0 && ipPools.length === 0 && (
<SectionBox title="IP Address Pools">
<p>
No kubevip ConfigMap found. IP pools are not configured (kube-vip-cloud-provider may not
be installed).
</p>
</SectionBox>
)}
{/* Leader Election Leases */}
{leases.length > 0 && (
<SectionBox title="Leader Election Leases">
<SimpleTable
columns={[
{ label: 'Name', getter: l => l.metadata.name },
{ label: 'Holder', getter: l => l.spec?.holderIdentity ?? '—' },
{ label: 'Duration (s)', getter: l => String(l.spec?.leaseDurationSeconds ?? '—') },
{ label: 'Transitions', getter: l => String(l.spec?.leaseTransitions ?? 0) },
{ label: 'Last Renewed', getter: l => formatAge(l.spec?.renewTime) },
]}
data={leases}
/>
</SectionBox>
)}
{/* Pod details */}
{kubeVipPods.length > 0 && (
<SectionBox title="kube-vip Pods">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.metadata.name },
{ label: 'Node', getter: p => p.spec?.nodeName ?? '—' },
{
label: 'Status',
getter: p => (
<StatusLabel status={phaseToStatus(p.status?.phase)}>
{p.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{
label: 'Ready',
getter: p => (isPodReady(p) ? 'Yes' : 'No'),
},
{ label: 'Image', getter: p => getPodImage(p) },
{ label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
]}
data={kubeVipPods}
/>
</SectionBox>
)}
{cloudProviderPods.length > 0 && (
<SectionBox title="Cloud Provider Pods">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.metadata.name },
{ label: 'Node', getter: p => p.spec?.nodeName ?? '—' },
{
label: 'Status',
getter: p => (
<StatusLabel status={phaseToStatus(p.status?.phase)}>
{p.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{ label: 'Image', getter: p => getPodImage(p) },
{ label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
]}
data={cloudProviderPods}
/>
</SectionBox>
)}
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import { defaultContext, makeSampleLease, makeSampleNode, makeSamplePod } from '../test-helpers';
import NodesPage from './NodesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('NodesPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<NodesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading nodes...');
});
it('shows error state', () => {
mockContext({ error: 'nodes error' });
render(<NodesPage />);
expect(screen.getByText('nodes error')).toBeInTheDocument();
});
it('renders control plane nodes section', () => {
const node = makeSampleNode();
const pod = makeSamplePod();
mockContext({ nodes: [node], kubeVipPods: [pod] });
render(<NodesPage />);
expect(screen.getByText('Control Plane Nodes (1)')).toBeInTheDocument();
expect(screen.getByText('node-1')).toBeInTheDocument();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
});
it('renders worker nodes section', () => {
const worker = makeSampleNode({
metadata: { name: 'worker-1', labels: { 'kubernetes.io/hostname': 'worker-1' } },
});
mockContext({ nodes: [worker] });
render(<NodesPage />);
expect(screen.getByText('Worker Nodes (1)')).toBeInTheDocument();
expect(screen.getByText('worker-1')).toBeInTheDocument();
});
it('shows leader status for nodes with matching lease', () => {
const node = makeSampleNode();
const lease = makeSampleLease();
const pod = makeSamplePod();
mockContext({ nodes: [node], leases: [lease], kubeVipPods: [pod] });
render(<NodesPage />);
// "Leader" appears as both a column header and a StatusLabel value
expect(screen.getAllByText('Leader').length).toBeGreaterThanOrEqual(2);
});
it('shows kube-vip pod status per node', () => {
const node = makeSampleNode();
const pod = makeSamplePod();
mockContext({ nodes: [node], kubeVipPods: [pod] });
render(<NodesPage />);
expect(screen.getByText('Running')).toBeInTheDocument();
});
it('shows kubelet version', () => {
const node = makeSampleNode();
mockContext({ nodes: [node] });
render(<NodesPage />);
expect(screen.getByText('v1.30.0')).toBeInTheDocument();
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* NodesPage — cluster nodes with kube-vip VIP assignments.
*
* Shows all nodes with their roles, readiness, kube-vip pod status,
* and any VIP labels applied by kube-vip.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import {
formatAge,
getNodeInternalIP,
getNodeVipLabel,
isControlPlaneNode,
isNodeReady,
} from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
export default function NodesPage() {
const { nodes, kubeVipPods, leases, loading, error } = useKubeVipContext();
if (loading) {
return <Loader title="Loading nodes..." />;
}
if (error) {
return (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
);
}
// Build a map of node → kube-vip pod
const podByNode = new Map<string, (typeof kubeVipPods)[0]>();
for (const pod of kubeVipPods) {
if (pod.spec?.nodeName) {
podByNode.set(pod.spec.nodeName, pod);
}
}
// Determine leader from leases
const leaderIdentities = new Set<string>();
for (const lease of leases) {
if (lease.spec?.holderIdentity) {
leaderIdentities.add(lease.spec.holderIdentity);
}
}
const controlPlane = nodes.filter(isControlPlaneNode);
const workers = nodes.filter(n => !isControlPlaneNode(n));
return (
<>
<SectionHeader title="kube-vip — Nodes" />
{controlPlane.length > 0 && (
<SectionBox title={`Control Plane Nodes (${controlPlane.length})`}>
<SimpleTable
columns={[
{ label: 'Name', getter: n => n.metadata.name },
{ label: 'IP', getter: n => getNodeInternalIP(n) },
{
label: 'Ready',
getter: n => (
<StatusLabel status={isNodeReady(n) ? 'success' : 'error'}>
{isNodeReady(n) ? 'Ready' : 'NotReady'}
</StatusLabel>
),
},
{
label: 'kube-vip Pod',
getter: n => {
const pod = podByNode.get(n.metadata.name);
if (!pod) return '—';
return (
<StatusLabel status={pod.status?.phase === 'Running' ? 'success' : 'warning'}>
{pod.status?.phase ?? 'Unknown'}
</StatusLabel>
);
},
},
{
label: 'Leader',
getter: n =>
leaderIdentities.has(n.metadata.name) ? (
<StatusLabel status="success">Leader</StatusLabel>
) : (
'—'
),
},
{ label: 'VIP Label', getter: n => getNodeVipLabel(n) ?? '—' },
{ label: 'Kubelet', getter: n => n.status?.nodeInfo?.kubeletVersion ?? '—' },
{ label: 'Age', getter: n => formatAge(n.metadata.creationTimestamp) },
]}
data={controlPlane}
/>
</SectionBox>
)}
{workers.length > 0 && (
<SectionBox title={`Worker Nodes (${workers.length})`}>
<SimpleTable
columns={[
{ label: 'Name', getter: n => n.metadata.name },
{ label: 'IP', getter: n => getNodeInternalIP(n) },
{
label: 'Ready',
getter: n => (
<StatusLabel status={isNodeReady(n) ? 'success' : 'error'}>
{isNodeReady(n) ? 'Ready' : 'NotReady'}
</StatusLabel>
),
},
{
label: 'kube-vip Pod',
getter: n => {
const pod = podByNode.get(n.metadata.name);
if (!pod) return '—';
return (
<StatusLabel status={pod.status?.phase === 'Running' ? 'success' : 'warning'}>
{pod.status?.phase ?? 'Unknown'}
</StatusLabel>
);
},
},
{ label: 'Kubelet', getter: n => n.status?.nodeInfo?.kubeletVersion ?? '—' },
{ label: 'Age', getter: n => formatAge(n.metadata.creationTimestamp) },
]}
data={workers}
/>
</SectionBox>
)}
</>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import {
defaultContext,
makeSampleLease,
makeSampleNode,
makeSamplePod,
makeSampleService,
} from '../test-helpers';
import OverviewPage from './OverviewPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('OverviewPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<OverviewPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading kube-vip data...');
});
it('shows error state', () => {
mockContext({ error: 'api error' });
render(<OverviewPage />);
expect(screen.getByText('api error')).toBeInTheDocument();
});
it('shows "not detected" when kube-vip is not installed', () => {
mockContext({ kubeVipInstalled: false });
render(<OverviewPage />);
expect(screen.getByText('kube-vip Not Detected')).toBeInTheDocument();
});
it('renders deployment info when installed', () => {
const pod = makeSamplePod();
mockContext({
kubeVipInstalled: true,
kubeVipPods: [pod],
kubeVipConfig: {
vip_arp: 'true',
cp_enable: 'true',
svc_enable: 'true',
address: '192.168.1.100',
},
});
render(<OverviewPage />);
expect(screen.getByText('Deployment')).toBeInTheDocument();
expect(screen.getByText('ARP')).toBeInTheDocument();
expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
});
it('renders LoadBalancer services table', () => {
const svc = makeSampleService();
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
loadBalancerServices: [svc],
});
render(<OverviewPage />);
expect(screen.getByText('my-service')).toBeInTheDocument();
expect(screen.getByText('192.168.1.200')).toBeInTheDocument();
});
it('renders IP pools when available', () => {
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
ipPools: [
{ name: 'range-global', type: 'range', value: '10.0.0.100-10.0.0.200', scope: 'global' },
],
});
render(<OverviewPage />);
expect(screen.getByText('range-global')).toBeInTheDocument();
expect(screen.getByText('10.0.0.100-10.0.0.200')).toBeInTheDocument();
});
it('shows cluster summary with node counts', () => {
const node = makeSampleNode();
const workerNode = makeSampleNode({
metadata: { name: 'worker-1', labels: { 'kubernetes.io/hostname': 'worker-1' } },
});
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
nodes: [node, workerNode],
});
render(<OverviewPage />);
expect(screen.getByText('Cluster Summary')).toBeInTheDocument();
});
it('shows leader from leases', () => {
const lease = makeSampleLease();
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
leases: [lease],
kubeVipConfig: { vip_arp: 'true', cp_enable: 'true', svc_enable: 'true' },
});
render(<OverviewPage />);
// "node-1" appears in both the Leader row and the pod table Node column;
// verify it appears at least twice (leader + pod row)
expect(screen.getAllByText('node-1').length).toBeGreaterThanOrEqual(2);
});
it('detects BGP mode', () => {
mockContext({
kubeVipInstalled: true,
kubeVipPods: [makeSamplePod()],
kubeVipConfig: { bgp_enable: 'true', cp_enable: 'true', svc_enable: 'true' },
});
render(<OverviewPage />);
expect(screen.getByText('BGP')).toBeInTheDocument();
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* OverviewPage — main dashboard for the kube-vip plugin.
*
* Shows: deployment status, VIP mode, leader election, service/node counts,
* IP pool summary, and pod health.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import {
formatAge,
getServiceVIPs,
isControlPlaneNode,
isEgressEnabled,
isKubeVipService,
isPodReady,
phaseToStatus,
} from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
export default function OverviewPage() {
const {
kubeVipInstalled,
daemonSetStatus,
kubeVipPods,
cloudProviderPods,
loadBalancerServices,
nodes,
leases,
ipPools,
kubeVipConfig,
loading,
error,
refresh,
} = useKubeVipContext();
if (loading) {
return <Loader title="Loading kube-vip data..." />;
}
const controlPlaneNodes = nodes.filter(isControlPlaneNode);
const readyPods = kubeVipPods.filter(isPodReady);
const kubeVipManaged = loadBalancerServices.filter(isKubeVipService);
const egressEnabled = loadBalancerServices.filter(isEgressEnabled);
// Detect mode from config
const mode =
kubeVipConfig['bgp_enable'] === 'true'
? 'BGP'
: kubeVipConfig['vip_arp'] === 'true'
? 'ARP'
: kubeVipPods.length > 0
? 'Unknown'
: '—';
const cpEnabled = kubeVipConfig['cp_enable'] === 'true';
const svcEnabled = kubeVipConfig['svc_enable'] === 'true';
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 leaderNode = cpLease?.spec?.holderIdentity ?? svcLease?.spec?.holderIdentity ?? '—';
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="kube-vip — Overview" />
<button
onClick={refresh}
aria-label="Refresh kube-vip data"
style={{
padding: '6px 16px',
backgroundColor: 'transparent',
color: 'var(--mui-palette-primary-main, #1976d2)',
border: '1px solid var(--mui-palette-primary-main, #1976d2)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
}}
>
Refresh
</button>
</div>
{!kubeVipInstalled && (
<SectionBox title="kube-vip Not Detected">
<NameValueTable
rows={[
{
name: 'Status',
value: (
<StatusLabel status="error">No kube-vip pods found in kube-system</StatusLabel>
),
},
{
name: 'Install',
value: 'See https://kube-vip.io/docs/installation/',
},
]}
/>
</SectionBox>
)}
{error && (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
)}
{kubeVipInstalled && (
<>
<SectionBox title="Deployment">
<NameValueTable
rows={[
{
name: 'Status',
value: (
<StatusLabel status={readyPods.length > 0 ? 'success' : 'error'}>
{readyPods.length > 0 ? 'Running' : 'Unhealthy'}
</StatusLabel>
),
},
{ name: 'Mode', value: mode },
{ name: 'Control Plane HA', value: cpEnabled ? 'Enabled' : 'Disabled' },
{ name: 'Service LoadBalancer', value: svcEnabled ? 'Enabled' : 'Disabled' },
...(cpEnabled ? [{ name: 'Control Plane VIP', value: controlPlaneVIP }] : []),
{ name: 'Leader', value: leaderNode },
{
name: 'Pods',
value: `${readyPods.length}/${kubeVipPods.length} ready`,
},
...(daemonSetStatus
? [
{
name: 'DaemonSet',
value: `${daemonSetStatus.numberReady ?? 0}/${
daemonSetStatus.desiredNumberScheduled ?? 0
} ready`,
},
]
: []),
...(cloudProviderPods.length > 0
? [
{
name: 'Cloud Provider',
value: (
<StatusLabel
status={cloudProviderPods.some(isPodReady) ? 'success' : 'warning'}
>
{cloudProviderPods.length} pod(s)
</StatusLabel>
),
},
]
: []),
]}
/>
</SectionBox>
<SectionBox title="Cluster Summary">
<NameValueTable
rows={[
{ name: 'Total Nodes', value: String(nodes.length) },
{ name: 'Control Plane Nodes', value: String(controlPlaneNodes.length) },
{ name: 'LoadBalancer Services', value: String(loadBalancerServices.length) },
{ name: 'kube-vip Managed', value: String(kubeVipManaged.length) },
...(egressEnabled.length > 0
? [
{
name: 'Egress Enabled',
value: String(egressEnabled.length),
},
]
: []),
{ name: 'IP Pools', value: String(ipPools.length) },
{ name: 'Leader Election Leases', value: String(leases.length) },
]}
/>
</SectionBox>
{ipPools.length > 0 && (
<SectionBox title="IP Pools">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.name },
{ label: 'Type', getter: p => p.type.toUpperCase() },
{ label: 'Value', getter: p => p.value },
{
label: 'Scope',
getter: p => (p.scope === 'namespace' ? p.namespace ?? '—' : 'Global'),
},
]}
data={ipPools}
/>
</SectionBox>
)}
{kubeVipPods.length > 0 && (
<SectionBox title="kube-vip Pods">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.metadata.name },
{ label: 'Node', getter: p => p.spec?.nodeName ?? '—' },
{
label: 'Status',
getter: p => (
<StatusLabel status={phaseToStatus(p.status?.phase)}>
{p.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{
label: 'Ready',
getter: p => (isPodReady(p) ? 'Yes' : 'No'),
},
{ label: 'Age', getter: p => formatAge(p.metadata.creationTimestamp) },
]}
data={kubeVipPods}
/>
</SectionBox>
)}
{loadBalancerServices.length > 0 && (
<SectionBox title="LoadBalancer Services">
<SimpleTable
columns={[
{ label: 'Name', getter: s => s.metadata.name },
{ label: 'Namespace', getter: s => s.metadata.namespace ?? '—' },
{ label: 'VIP', getter: s => getServiceVIPs(s).join(', ') || '—' },
{
label: 'Ports',
getter: s =>
s.spec.ports
?.map(
(p: { port: number; protocol?: string }) =>
`${p.port}/${p.protocol ?? 'TCP'}`
)
.join(', ') ?? '—',
},
{ label: 'Age', getter: s => formatAge(s.metadata.creationTimestamp) },
]}
data={loadBalancerServices}
/>
</SectionBox>
)}
</>
)}
</>
);
}
@@ -0,0 +1,133 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import { defaultContext, makeSampleService } from '../test-helpers';
import ServiceDetailSection from './ServiceDetailSection';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('ServiceDetailSection', () => {
it('returns null when loading', () => {
mockContext({ loading: true });
const { container } = render(
<ServiceDetailSection
resource={{
metadata: { name: 'svc', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(container.innerHTML).toBe('');
});
it('returns null for non-LoadBalancer services', () => {
mockContext();
const { container } = render(
<ServiceDetailSection
resource={{ metadata: { name: 'svc', namespace: 'default' }, spec: { type: 'ClusterIP' } }}
/>
);
expect(container.innerHTML).toBe('');
});
it('returns null when service is not in filtered list', () => {
mockContext({ loadBalancerServices: [] });
const { container } = render(
<ServiceDetailSection
resource={{
metadata: { name: 'unknown', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(container.innerHTML).toBe('');
});
it('renders kube-vip details for matching LoadBalancer service', () => {
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(
<ServiceDetailSection
resource={{
metadata: { name: 'my-service', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(screen.getByText('kube-vip Details')).toBeInTheDocument();
expect(screen.getByText('192.168.1.200')).toBeInTheDocument();
});
it('shows VIP host when available', () => {
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(
<ServiceDetailSection
resource={{
metadata: { name: 'my-service', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
// "node-1" appears in both the VIP Host row and the vipHost annotation
expect(screen.getAllByText('node-1').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('VIP Host Node')).toBeInTheDocument();
});
it('shows egress label when enabled', () => {
const svc = makeSampleService({
metadata: {
name: 'egress-svc',
namespace: 'default',
annotations: {
'kube-vip.io/loadbalancerIPs': '10.0.0.1',
'kube-vip.io/egress': 'true',
'kube-vip.io/vipHost': 'node-1',
},
},
});
mockContext({ loadBalancerServices: [svc] });
render(
<ServiceDetailSection
resource={{
metadata: { name: 'egress-svc', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(screen.getByText('Egress')).toBeInTheDocument();
});
it('shows ignored warning when service is ignored', () => {
const svc = makeSampleService({
metadata: {
name: 'ignored-svc',
namespace: 'default',
annotations: {
'kube-vip.io/ignore': 'true',
'kube-vip.io/loadbalancerIPs': '10.0.0.1',
},
},
});
mockContext({ loadBalancerServices: [svc] });
render(
<ServiceDetailSection
resource={{
metadata: { name: 'ignored-svc', namespace: 'default' },
spec: { type: 'LoadBalancer' },
}}
/>
);
expect(screen.getByText(/ignoring this service/)).toBeInTheDocument();
});
});
+97
View File
@@ -0,0 +1,97 @@
/**
* ServiceDetailSection — injected into Headlamp's native Service detail view.
*
* Displays kube-vip-specific information for LoadBalancer services:
* VIP assignments, annotations, egress status.
*/
import {
NameValueTable,
SectionBox,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import {
ANNOTATION_LOADBALANCER_IPS,
getServiceVIPs,
getVipHost,
isEgressEnabled,
isServiceIgnored,
KUBE_VIP_ANNOTATION_PREFIX,
} from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
interface ServiceDetailSectionProps {
resource: { metadata: { name: string; namespace?: string }; spec?: { type?: string } };
}
export default function ServiceDetailSection({ resource }: ServiceDetailSectionProps) {
const { loadBalancerServices, loading } = useKubeVipContext();
if (loading) return null;
// Only show for LoadBalancer services
if (resource.spec?.type !== 'LoadBalancer') return null;
// Find the matching service in our filtered list
const svc = loadBalancerServices.find(
s =>
s.metadata.name === resource.metadata.name &&
s.metadata.namespace === resource.metadata.namespace
);
if (!svc) return null;
// Check if this service has any kube-vip annotations
const annotations = svc.metadata.annotations ?? {};
const hasKubeVipAnnotations = Object.keys(annotations).some(k =>
k.startsWith(KUBE_VIP_ANNOTATION_PREFIX)
);
// If no kube-vip annotations, still show VIP info from status
const vips = getServiceVIPs(svc);
if (!hasKubeVipAnnotations && vips.length === 0) return null;
const vipHost = getVipHost(svc);
const kubeVipAnnotations = Object.entries(annotations).filter(([key]) =>
key.startsWith(KUBE_VIP_ANNOTATION_PREFIX)
);
return (
<SectionBox title="kube-vip Details">
<NameValueTable
rows={[
{
name: 'VIP',
value: vips.length > 0 ? vips.join(', ') : 'Pending',
},
...(vipHost ? [{ name: 'VIP Host Node', value: vipHost }] : []),
...(isEgressEnabled(svc)
? [
{
name: 'Egress',
value: <StatusLabel status="success">Enabled</StatusLabel>,
},
]
: []),
...(isServiceIgnored(svc)
? [
{
name: 'Ignored',
value: (
<StatusLabel status="warning">kube-vip is ignoring this service</StatusLabel>
),
},
]
: []),
...kubeVipAnnotations
.filter(([key]) => key !== ANNOTATION_LOADBALANCER_IPS)
.map(([key, value]) => ({
name: key.replace(KUBE_VIP_ANNOTATION_PREFIX, ''),
value,
})),
]}
/>
</SectionBox>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/kube-vip/services', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/KubeVipDataContext');
import { useKubeVipContext } from '../api/KubeVipDataContext';
import { defaultContext, makeSampleService } from '../test-helpers';
import ServicesPage from './ServicesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useKubeVipContext).mockReturnValue(defaultContext(overrides));
}
describe('ServicesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<ServicesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading services...');
});
it('shows error state', () => {
mockContext({ error: 'fetch failed' });
render(<ServicesPage />);
expect(screen.getByText('fetch failed')).toBeInTheDocument();
});
it('shows empty message when no services', () => {
mockContext({ loadBalancerServices: [] });
render(<ServicesPage />);
expect(screen.getByText('No LoadBalancer services found.')).toBeInTheDocument();
});
it('renders services table', () => {
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
expect(screen.getByText('my-service')).toBeInTheDocument();
expect(screen.getByText('192.168.1.200')).toBeInTheDocument();
});
it('opens detail panel when clicking service name', () => {
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
fireEvent.click(screen.getByText('my-service'));
expect(mockPush).toHaveBeenCalledWith('/kube-vip/services#default/my-service');
});
it('renders detail panel when hash is set', () => {
mockHash = '#default/my-service';
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
expect(screen.getByText('Service Details')).toBeInTheDocument();
});
it('closes panel via backdrop click', () => {
mockHash = '#default/my-service';
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/kube-vip/services');
});
it('closes panel on Escape key', () => {
mockHash = '#default/my-service';
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/kube-vip/services');
});
it('shows kube-vip annotations in detail panel', () => {
mockHash = '#default/my-service';
const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
expect(screen.getByText('kube-vip Annotations')).toBeInTheDocument();
expect(screen.getByText('loadbalancerIPs')).toBeInTheDocument();
});
it('shows egress column for egress-enabled service', () => {
const svc = makeSampleService({
metadata: {
name: 'egress-svc',
namespace: 'default',
annotations: {
'kube-vip.io/loadbalancerIPs': '10.0.0.1',
'kube-vip.io/egress': 'true',
},
},
});
mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />);
// The "Yes" text appears in the Egress column
const cells = screen.getAllByRole('cell');
expect(cells.some(c => c.textContent === 'Yes')).toBe(true);
});
});
+265
View File
@@ -0,0 +1,265 @@
/**
* ServicesPage — LoadBalancer services managed by kube-vip.
*
* Shows all type:LoadBalancer services with VIP assignments, ports,
* kube-vip annotations, and egress status.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import {
formatAge,
getServiceVIPs,
getVipHost,
isEgressEnabled,
isKubeVipService,
isServiceIgnored,
KUBE_VIP_ANNOTATION_PREFIX,
KubeVipService,
} from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext';
export default function ServicesPage() {
const { loadBalancerServices, loading, error } = useKubeVipContext();
const location = useLocation();
const history = useHistory();
const selectedName = location.hash ? decodeURIComponent(location.hash.slice(1)) : null;
const selectedService = selectedName
? loadBalancerServices.find(s => `${s.metadata.namespace}/${s.metadata.name}` === selectedName)
: null;
const closePanel = () => history.push(location.pathname);
useEffect(() => {
if (!selectedName) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') closePanel();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
if (loading) {
return <Loader title="Loading services..." />;
}
if (error) {
return (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
);
}
if (loadBalancerServices.length === 0) {
return (
<>
<SectionHeader title="kube-vip — Services" />
<SectionBox>
<p>No LoadBalancer services found.</p>
</SectionBox>
</>
);
}
return (
<>
<SectionHeader title="kube-vip — Services" />
<SectionBox title={`LoadBalancer Services (${loadBalancerServices.length})`}>
<SimpleTable
columns={[
{
label: 'Name',
getter: s => (
<a
href="#"
onClick={e => {
e.preventDefault();
history.push(`${location.pathname}#${s.metadata.namespace}/${s.metadata.name}`);
}}
style={{ color: 'var(--mui-palette-primary-main, #1976d2)', cursor: 'pointer' }}
>
{s.metadata.name}
</a>
),
},
{ label: 'Namespace', getter: s => s.metadata.namespace ?? '—' },
{ label: 'VIP', getter: s => getServiceVIPs(s).join(', ') || 'Pending' },
{
label: 'Ports',
getter: s =>
s.spec.ports
?.map(
(p: { port: number; protocol?: string }) => `${p.port}/${p.protocol ?? 'TCP'}`
)
.join(', ') ?? '—',
},
{ label: 'VIP Host', getter: s => getVipHost(s) ?? '—' },
{
label: 'kube-vip',
getter: s => (
<StatusLabel status={isKubeVipService(s) ? 'success' : ''}>
{isKubeVipService(s) ? 'Yes' : '—'}
</StatusLabel>
),
},
{
label: 'Egress',
getter: s => (isEgressEnabled(s) ? 'Yes' : '—'),
},
{ label: 'Age', getter: s => formatAge(s.metadata.creationTimestamp) },
]}
data={loadBalancerServices}
/>
</SectionBox>
{/* Detail slide-in panel */}
{selectedService && (
<>
<div
onClick={closePanel}
aria-label="Close panel backdrop"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.3)',
zIndex: 1200,
}}
/>
<ServiceDetailPanel service={selectedService} onClose={closePanel} />
</>
)}
</>
);
}
function ServiceDetailPanel({
service,
onClose,
}: {
service: KubeVipService;
onClose: () => void;
}) {
const vips = getServiceVIPs(service);
const vipHost = getVipHost(service);
const annotations = service.metadata.annotations ?? {};
const kubeVipAnnotations = Object.entries(annotations).filter(([key]) =>
key.startsWith(KUBE_VIP_ANNOTATION_PREFIX)
);
return (
<div
style={{
position: 'fixed',
top: 0,
right: 0,
width: '480px',
height: '100vh',
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
boxShadow: '-4px 0 12px rgba(0,0,0,0.15)',
zIndex: 1300,
overflowY: 'auto',
padding: '24px',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<h2 style={{ margin: 0, fontSize: '18px' }}>Service Details</h2>
<button
onClick={onClose}
aria-label="Close panel"
style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer' }}
>
&times;
</button>
</div>
<SectionBox title="General">
<NameValueTable
rows={[
{ name: 'Name', value: service.metadata.name },
{ name: 'Namespace', value: service.metadata.namespace ?? '—' },
{ name: 'Type', value: service.spec.type ?? '—' },
{ name: 'Cluster IP', value: service.spec.clusterIP ?? '—' },
{ name: 'VIP', value: vips.join(', ') || 'Pending' },
...(vipHost ? [{ name: 'VIP Host Node', value: vipHost }] : []),
{ name: 'External Traffic Policy', value: service.spec.externalTrafficPolicy ?? '—' },
{ name: 'Age', value: formatAge(service.metadata.creationTimestamp) },
]}
/>
</SectionBox>
{service.spec.ports && service.spec.ports.length > 0 && (
<SectionBox title="Ports">
<SimpleTable
columns={[
{ label: 'Name', getter: p => p.name ?? '—' },
{ label: 'Port', getter: p => String(p.port) },
{ label: 'Target', getter: p => String(p.targetPort ?? '—') },
{ label: 'Protocol', getter: p => p.protocol ?? 'TCP' },
...(service.spec.ports?.some((p: { nodePort?: number }) => p.nodePort)
? [
{
label: 'NodePort',
getter: (p: { nodePort?: number }) => String(p.nodePort ?? '—'),
},
]
: []),
]}
data={service.spec.ports}
/>
</SectionBox>
)}
{kubeVipAnnotations.length > 0 && (
<SectionBox title="kube-vip Annotations">
<NameValueTable
rows={kubeVipAnnotations.map(([key, value]) => ({
name: key.replace(KUBE_VIP_ANNOTATION_PREFIX, ''),
value,
}))}
/>
</SectionBox>
)}
{isServiceIgnored(service) && (
<SectionBox title="Notice">
<NameValueTable
rows={[
{
name: 'Ignored',
value: (
<StatusLabel status="warning">
This service has kube-vip.io/ignore=true kube-vip will not manage it
</StatusLabel>
),
},
]}
/>
</SectionBox>
)}
</div>
);
}
@@ -0,0 +1,95 @@
/**
* Lightweight mock implementations of @kinvolk/headlamp-plugin/lib/CommonComponents.
* Used via vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', ...).
*
* Uses React.createElement instead of JSX since this file is .ts (not .tsx).
*/
import React from 'react';
type RC = React.ReactNode;
export const Loader = ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'loader' }, title);
export const SectionBox = ({ title, children }: { title?: string; children?: RC }) =>
React.createElement(
'div',
{ 'data-testid': 'section-box', 'data-title': title },
title ? React.createElement('h3', null, title) : null,
children
);
export const SectionHeader = ({ title }: { title: string }) =>
React.createElement('h1', { 'data-testid': 'section-header' }, title);
export const SimpleTable = ({
columns,
data,
emptyMessage,
}: {
columns: Array<{ label: string; getter: (item: unknown) => RC }>;
data: unknown[];
emptyMessage?: string;
}) => {
if (data.length === 0 && emptyMessage) {
return React.createElement('div', { 'data-testid': 'empty-table' }, emptyMessage);
}
return React.createElement(
'table',
{ 'data-testid': 'simple-table' },
React.createElement(
'thead',
null,
React.createElement(
'tr',
null,
columns.map(col => React.createElement('th', { key: col.label }, col.label))
)
),
React.createElement(
'tbody',
null,
data.map((item, i) =>
React.createElement(
'tr',
{ key: i },
columns.map(col => React.createElement('td', { key: col.label }, col.getter(item)))
)
)
)
);
};
export const NameValueTable = ({ rows }: { rows: Array<{ name: string; value: RC }> }) =>
React.createElement(
'table',
{ 'data-testid': 'name-value-table' },
React.createElement(
'tbody',
null,
rows.map(row =>
React.createElement(
'tr',
{ key: row.name },
React.createElement('td', null, row.name),
React.createElement('td', null, row.value)
)
)
)
);
export const StatusLabel = ({ status, children }: { status: string; children?: RC }) =>
React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children);
export const PercentageBar = ({
data,
}: {
data: Array<{ name: string; value: number }>;
total: number;
}) =>
React.createElement(
'div',
{ 'data-testid': 'percentage-bar' },
data.map(d => React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`))
);
+128
View File
@@ -0,0 +1,128 @@
/**
* headlamp-kube-vip-plugin — entry point.
*
* Registers sidebar entries, routes, and detail view sections for
* kube-vip virtual IP and load balancer visibility in Headlamp.
*/
import {
registerDetailsViewSection,
registerRoute,
registerSidebarEntry,
} from '@kinvolk/headlamp-plugin/lib';
import React from 'react';
import { KubeVipDataProvider } from './api/KubeVipDataContext';
import ConfigPage from './components/ConfigPage';
import NodesPage from './components/NodesPage';
import OverviewPage from './components/OverviewPage';
import ServiceDetailSection from './components/ServiceDetailSection';
import ServicesPage from './components/ServicesPage';
// ---------------------------------------------------------------------------
// Sidebar entries
// ---------------------------------------------------------------------------
registerSidebarEntry({
parent: null,
name: 'kube-vip',
label: 'kube-vip',
url: '/kube-vip',
icon: 'mdi:ip-network',
});
registerSidebarEntry({
parent: 'kube-vip',
name: 'kube-vip-overview',
label: 'Overview',
url: '/kube-vip',
icon: 'mdi:view-dashboard',
});
registerSidebarEntry({
parent: 'kube-vip',
name: 'kube-vip-services',
label: 'Services',
url: '/kube-vip/services',
icon: 'mdi:lan',
});
registerSidebarEntry({
parent: 'kube-vip',
name: 'kube-vip-nodes',
label: 'Nodes',
url: '/kube-vip/nodes',
icon: 'mdi:server',
});
registerSidebarEntry({
parent: 'kube-vip',
name: 'kube-vip-config',
label: 'Configuration',
url: '/kube-vip/config',
icon: 'mdi:cog',
});
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
registerRoute({
path: '/kube-vip',
sidebar: 'kube-vip-overview',
name: 'kube-vip-overview',
exact: true,
component: () => (
<KubeVipDataProvider>
<OverviewPage />
</KubeVipDataProvider>
),
});
registerRoute({
path: '/kube-vip/services',
sidebar: 'kube-vip-services',
name: 'kube-vip-services',
exact: true,
component: () => (
<KubeVipDataProvider>
<ServicesPage />
</KubeVipDataProvider>
),
});
registerRoute({
path: '/kube-vip/nodes',
sidebar: 'kube-vip-nodes',
name: 'kube-vip-nodes',
exact: true,
component: () => (
<KubeVipDataProvider>
<NodesPage />
</KubeVipDataProvider>
),
});
registerRoute({
path: '/kube-vip/config',
sidebar: 'kube-vip-config',
name: 'kube-vip-config',
exact: true,
component: () => (
<KubeVipDataProvider>
<ConfigPage />
</KubeVipDataProvider>
),
});
// ---------------------------------------------------------------------------
// Detail view section — Service pages (LoadBalancer type)
// ---------------------------------------------------------------------------
registerDetailsViewSection(({ resource }) => {
if (resource?.kind !== 'Service') return null;
return (
<KubeVipDataProvider>
<ServiceDetailSection resource={resource} />
</KubeVipDataProvider>
);
});
+164
View File
@@ -0,0 +1,164 @@
/**
* Shared test helpers: mock factories, fixtures, and context setup
* for component tests.
*/
import { vi } from 'vitest';
import type { KubeVipLease, KubeVipNode, KubeVipPod, KubeVipService } from './api/k8s';
import type { KubeVipContextValue } from './api/KubeVipDataContext';
// ---------------------------------------------------------------------------
// Default context value (everything empty / zeroed)
// ---------------------------------------------------------------------------
export function defaultContext(overrides?: Partial<KubeVipContextValue>): KubeVipContextValue {
return {
kubeVipInstalled: false,
daemonSetStatus: null,
kubeVipPods: [],
cloudProviderPods: [],
loadBalancerServices: [],
nodes: [],
leases: [],
ipPools: [],
configMapData: {},
kubeVipConfig: {},
loading: false,
error: null,
refresh: vi.fn(),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Sample fixtures
// ---------------------------------------------------------------------------
export function makeSamplePod(overrides?: Partial<KubeVipPod>): KubeVipPod {
return {
metadata: {
name: 'kube-vip-ds-abc12',
namespace: 'kube-system',
creationTimestamp: '2025-01-01T00:00:00Z',
labels: { 'app.kubernetes.io/name': 'kube-vip-ds' },
},
spec: {
nodeName: 'node-1',
hostNetwork: true,
containers: [
{
name: 'kube-vip',
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
env: [
{ name: 'address', value: '192.168.1.100' },
{ name: 'vip_arp', value: 'true' },
{ name: 'cp_enable', value: 'true' },
{ name: 'svc_enable', value: 'true' },
{ name: 'vip_interface', value: 'eth0' },
{ name: 'vip_leaderelection', value: 'true' },
],
},
],
},
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{
name: 'kube-vip',
ready: true,
restartCount: 0,
image: 'ghcr.io/kube-vip/kube-vip:v0.8.0',
state: { running: { startedAt: '2025-01-01T00:00:00Z' } },
},
],
hostIP: '10.0.0.1',
podIP: '10.0.0.1',
},
...overrides,
};
}
export function makeSampleService(overrides?: Partial<KubeVipService>): KubeVipService {
return {
metadata: {
name: 'my-service',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
annotations: {
'kube-vip.io/loadbalancerIPs': '192.168.1.200',
'kube-vip.io/vipHost': 'node-1',
},
},
spec: {
type: 'LoadBalancer',
clusterIP: '10.96.0.100',
externalTrafficPolicy: 'Cluster',
ports: [{ name: 'http', port: 80, targetPort: 8080, protocol: 'TCP' }],
},
status: {
loadBalancer: {
ingress: [{ ip: '192.168.1.200' }],
},
},
...overrides,
};
}
export function makeSampleNode(overrides?: Partial<KubeVipNode>): KubeVipNode {
return {
metadata: {
name: 'node-1',
creationTimestamp: '2025-01-01T00:00:00Z',
labels: {
'node-role.kubernetes.io/control-plane': '',
'kubernetes.io/hostname': 'node-1',
},
},
spec: {
podCIDR: '10.244.0.0/24',
},
status: {
conditions: [{ type: 'Ready', status: 'True' }],
addresses: [
{ type: 'InternalIP', address: '10.0.0.1' },
{ type: 'Hostname', address: 'node-1' },
],
nodeInfo: {
kubeletVersion: 'v1.30.0',
osImage: 'Ubuntu 22.04',
containerRuntimeVersion: 'containerd://1.7.0',
architecture: 'amd64',
},
},
...overrides,
};
}
export function makeSampleLease(overrides?: Partial<KubeVipLease>): KubeVipLease {
return {
metadata: {
name: 'plndr-cp-lock',
namespace: 'kube-system',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
holderIdentity: 'node-1',
leaseDurationSeconds: 15,
renewTime: '2025-01-01T01:00:00Z',
leaseTransitions: 3,
},
...overrides,
};
}
export function makeSampleDaemonSetStatus(): KubeVipContextValue['daemonSetStatus'] {
return {
desiredNumberScheduled: 3,
currentNumberScheduled: 3,
numberReady: 3,
numberAvailable: 3,
updatedNumberScheduled: 3,
numberMisscheduled: 0,
};
}