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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' }}
|
||||
>
|
||||
×
|
||||
</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}`))
|
||||
);
|
||||
Reference in New Issue
Block a user