fix: resolve bugs in ServicesPage, NodesPage, and k8s helpers

- Add missing useEffect dependency array and useCallback for closePanel
- Fix invalid StatusLabel status="" to "info" for non-kube-vip services
- Add ARIA dialog attributes to service detail panel
- Use phaseToStatus() in NodesPage instead of hardcoded Running check
- Remove dead code in getNodeVipLabel (label keys never contain =)
- Simplify redundant lease lookup in OverviewPage
- Fix 46 ESLint indentation warnings
- Add CHANGELOG.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DevContainer User
2026-03-04 12:43:23 +00:00
parent aa676e8300
commit f26d1414b2
8 changed files with 113 additions and 70 deletions
+40
View File
@@ -0,0 +1,40 @@
# Changelog
## [0.1.3] - 2026-03-04
### Fixed
- Fix missing `useEffect` dependency array on Escape key listener in ServicesPage (re-registered every render)
- Wrap `closePanel` in `useCallback` to stabilize effect dependencies
- Fix invalid empty string `StatusLabel` status for non-kube-vip services (now uses `"info"`)
- Add ARIA `role="dialog"`, `aria-modal`, and `aria-label` to service detail slide-in panel
- Replace invalid `aria-label` on backdrop div with `role="presentation"`
- Use `phaseToStatus()` for pod status in NodesPage instead of hardcoded check (Failed pods now correctly show error)
- Remove unreachable `startsWith('kube-vip.io/has-ip=')` branch in `getNodeVipLabel` (label keys never contain `=`)
- Simplify redundant lease lookup conditions in OverviewPage
- Fix 46 ESLint indentation warnings across all source files
## [0.1.2] - 2025-05-20
### Fixed
- Add `--allow-same-version` flag for idempotent release retries
- Use `action-gh-release` instead of `gh` CLI for release creation
## [0.1.1] - 2025-05-20
### Fixed
- Remove redundant `mv` in release workflow
- Move Node.js setup before `npm version` in release workflow
## [0.1.0] - 2025-05-20
### Added
- Initial release
- Overview dashboard with deployment status, VIP mode, leader election
- LoadBalancer services page with VIP assignments and detail panel
- Nodes page with kube-vip pod status and leader designation
- Configuration page with DaemonSet config, IP pools, leases
- Service detail section injected into native Headlamp Service views
+2 -3
View File
@@ -210,7 +210,6 @@ export function isControlPlaneNode(node: KubeVipNode): boolean {
export function getNodeVipLabel(node: KubeVipNode): string | undefined { export function getNodeVipLabel(node: KubeVipNode): string | undefined {
const labels = node.metadata.labels ?? {}; const labels = node.metadata.labels ?? {};
for (const [key, value] of Object.entries(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; if (key === 'kube-vip.io/has-ip') return value;
} }
return undefined; return undefined;
@@ -351,8 +350,8 @@ export function parseIPPools(data: Record<string, string> | undefined): IPPool[]
const type = poolName.startsWith('range-') const type = poolName.startsWith('range-')
? 'range' ? 'range'
: poolName.startsWith('cidr-') : poolName.startsWith('cidr-')
? 'cidr' ? 'cidr'
: 'unknown'; : 'unknown';
pools.push({ pools.push({
name: poolName, name: poolName,
type, type,
+9 -9
View File
@@ -122,15 +122,15 @@ export default function ConfigPage() {
{ name: 'Updated', value: String(daemonSetStatus.updatedNumberScheduled ?? 0) }, { name: 'Updated', value: String(daemonSetStatus.updatedNumberScheduled ?? 0) },
...(daemonSetStatus.numberMisscheduled ...(daemonSetStatus.numberMisscheduled
? [ ? [
{ {
name: 'Misscheduled', name: 'Misscheduled',
value: ( value: (
<StatusLabel status="warning"> <StatusLabel status="warning">
{daemonSetStatus.numberMisscheduled} {daemonSetStatus.numberMisscheduled}
</StatusLabel> </StatusLabel>
), ),
}, },
] ]
: []), : []),
]} ]}
/> />
+3 -2
View File
@@ -20,6 +20,7 @@ import {
getNodeVipLabel, getNodeVipLabel,
isControlPlaneNode, isControlPlaneNode,
isNodeReady, isNodeReady,
phaseToStatus,
} from '../api/k8s'; } from '../api/k8s';
import { useKubeVipContext } from '../api/KubeVipDataContext'; import { useKubeVipContext } from '../api/KubeVipDataContext';
@@ -83,7 +84,7 @@ export default function NodesPage() {
const pod = podByNode.get(n.metadata.name); const pod = podByNode.get(n.metadata.name);
if (!pod) return '—'; if (!pod) return '—';
return ( return (
<StatusLabel status={pod.status?.phase === 'Running' ? 'success' : 'warning'}> <StatusLabel status={phaseToStatus(pod.status?.phase)}>
{pod.status?.phase ?? 'Unknown'} {pod.status?.phase ?? 'Unknown'}
</StatusLabel> </StatusLabel>
); );
@@ -127,7 +128,7 @@ export default function NodesPage() {
const pod = podByNode.get(n.metadata.name); const pod = podByNode.get(n.metadata.name);
if (!pod) return '—'; if (!pod) return '—';
return ( return (
<StatusLabel status={pod.status?.phase === 'Running' ? 'success' : 'warning'}> <StatusLabel status={phaseToStatus(pod.status?.phase)}>
{pod.status?.phase ?? 'Unknown'} {pod.status?.phase ?? 'Unknown'}
</StatusLabel> </StatusLabel>
); );
+29 -33
View File
@@ -55,22 +55,18 @@ export default function OverviewPage() {
kubeVipConfig['bgp_enable'] === 'true' kubeVipConfig['bgp_enable'] === 'true'
? 'BGP' ? 'BGP'
: kubeVipConfig['vip_arp'] === 'true' : kubeVipConfig['vip_arp'] === 'true'
? 'ARP' ? 'ARP'
: kubeVipPods.length > 0 : kubeVipPods.length > 0
? 'Unknown' ? 'Unknown'
: '—'; : '—';
const cpEnabled = kubeVipConfig['cp_enable'] === 'true'; const cpEnabled = kubeVipConfig['cp_enable'] === 'true';
const svcEnabled = kubeVipConfig['svc_enable'] === 'true'; const svcEnabled = kubeVipConfig['svc_enable'] === 'true';
const controlPlaneVIP = kubeVipConfig['address'] ?? '—'; const controlPlaneVIP = kubeVipConfig['address'] ?? '—';
// Find leader from leases // Find leader from leases
const cpLease = leases.find( const cpLease = leases.find(l => l.metadata.name.startsWith('plndr-cp-lock'));
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'));
);
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 ?? '—'; const leaderNode = cpLease?.spec?.holderIdentity ?? svcLease?.spec?.holderIdentity ?? '—';
return ( return (
@@ -153,27 +149,27 @@ export default function OverviewPage() {
}, },
...(daemonSetStatus ...(daemonSetStatus
? [ ? [
{ {
name: 'DaemonSet', name: 'DaemonSet',
value: `${daemonSetStatus.numberReady ?? 0}/${ value: `${daemonSetStatus.numberReady ?? 0}/${
daemonSetStatus.desiredNumberScheduled ?? 0 daemonSetStatus.desiredNumberScheduled ?? 0
} ready`, } ready`,
}, },
] ]
: []), : []),
...(cloudProviderPods.length > 0 ...(cloudProviderPods.length > 0
? [ ? [
{ {
name: 'Cloud Provider', name: 'Cloud Provider',
value: ( value: (
<StatusLabel <StatusLabel
status={cloudProviderPods.some(isPodReady) ? 'success' : 'warning'} status={cloudProviderPods.some(isPodReady) ? 'success' : 'warning'}
> >
{cloudProviderPods.length} pod(s) {cloudProviderPods.length} pod(s)
</StatusLabel> </StatusLabel>
), ),
}, },
] ]
: []), : []),
]} ]}
/> />
@@ -188,11 +184,11 @@ export default function OverviewPage() {
{ name: 'kube-vip Managed', value: String(kubeVipManaged.length) }, { name: 'kube-vip Managed', value: String(kubeVipManaged.length) },
...(egressEnabled.length > 0 ...(egressEnabled.length > 0
? [ ? [
{ {
name: 'Egress Enabled', name: 'Egress Enabled',
value: String(egressEnabled.length), value: String(egressEnabled.length),
}, },
] ]
: []), : []),
{ name: 'IP Pools', value: String(ipPools.length) }, { name: 'IP Pools', value: String(ipPools.length) },
{ name: 'Leader Election Leases', value: String(leases.length) }, { name: 'Leader Election Leases', value: String(leases.length) },
+12 -12
View File
@@ -68,21 +68,21 @@ export default function ServiceDetailSection({ resource }: ServiceDetailSectionP
...(vipHost ? [{ name: 'VIP Host Node', value: vipHost }] : []), ...(vipHost ? [{ name: 'VIP Host Node', value: vipHost }] : []),
...(isEgressEnabled(svc) ...(isEgressEnabled(svc)
? [ ? [
{ {
name: 'Egress', name: 'Egress',
value: <StatusLabel status="success">Enabled</StatusLabel>, value: <StatusLabel status="success">Enabled</StatusLabel>,
}, },
] ]
: []), : []),
...(isServiceIgnored(svc) ...(isServiceIgnored(svc)
? [ ? [
{ {
name: 'Ignored', name: 'Ignored',
value: ( value: (
<StatusLabel status="warning">kube-vip is ignoring this service</StatusLabel> <StatusLabel status="warning">kube-vip is ignoring this service</StatusLabel>
), ),
}, },
] ]
: []), : []),
...kubeVipAnnotations ...kubeVipAnnotations
.filter(([key]) => key !== ANNOTATION_LOADBALANCER_IPS) .filter(([key]) => key !== ANNOTATION_LOADBALANCER_IPS)
+1 -1
View File
@@ -76,7 +76,7 @@ describe('ServicesPage', () => {
const svc = makeSampleService(); const svc = makeSampleService();
mockContext({ loadBalancerServices: [svc] }); mockContext({ loadBalancerServices: [svc] });
render(<ServicesPage />); render(<ServicesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop')); fireEvent.click(screen.getByTestId('panel-backdrop'));
expect(mockPush).toHaveBeenCalledWith('/kube-vip/services'); expect(mockPush).toHaveBeenCalledWith('/kube-vip/services');
}); });
+17 -10
View File
@@ -13,7 +13,7 @@ import {
SimpleTable, SimpleTable,
StatusLabel, StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { import {
formatAge, formatAge,
@@ -38,7 +38,10 @@ export default function ServicesPage() {
? loadBalancerServices.find(s => `${s.metadata.namespace}/${s.metadata.name}` === selectedName) ? loadBalancerServices.find(s => `${s.metadata.namespace}/${s.metadata.name}` === selectedName)
: null; : null;
const closePanel = () => history.push(location.pathname); const closePanel = useCallback(
() => history.push(location.pathname),
[history, location.pathname]
);
useEffect(() => { useEffect(() => {
if (!selectedName) return; if (!selectedName) return;
@@ -47,7 +50,7 @@ export default function ServicesPage() {
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}); }, [selectedName, closePanel]);
if (loading) { if (loading) {
return <Loader title="Loading services..." />; return <Loader title="Loading services..." />;
@@ -111,7 +114,7 @@ export default function ServicesPage() {
{ {
label: 'kube-vip', label: 'kube-vip',
getter: s => ( getter: s => (
<StatusLabel status={isKubeVipService(s) ? 'success' : ''}> <StatusLabel status={isKubeVipService(s) ? 'success' : 'info'}>
{isKubeVipService(s) ? 'Yes' : '—'} {isKubeVipService(s) ? 'Yes' : '—'}
</StatusLabel> </StatusLabel>
), ),
@@ -130,8 +133,9 @@ export default function ServicesPage() {
{selectedService && ( {selectedService && (
<> <>
<div <div
role="presentation"
data-testid="panel-backdrop"
onClick={closePanel} onClick={closePanel}
aria-label="Close panel backdrop"
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, top: 0,
@@ -165,6 +169,9 @@ function ServiceDetailPanel({
return ( return (
<div <div
role="dialog"
aria-modal="true"
aria-label="Service Details"
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, top: 0,
@@ -221,11 +228,11 @@ function ServiceDetailPanel({
{ label: 'Protocol', getter: p => p.protocol ?? 'TCP' }, { label: 'Protocol', getter: p => p.protocol ?? 'TCP' },
...(service.spec.ports?.some((p: { nodePort?: number }) => p.nodePort) ...(service.spec.ports?.some((p: { nodePort?: number }) => p.nodePort)
? [ ? [
{ {
label: 'NodePort', label: 'NodePort',
getter: (p: { nodePort?: number }) => String(p.nodePort ?? '—'), getter: (p: { nodePort?: number }) => String(p.nodePort ?? '—'),
}, },
] ]
: []), : []),
]} ]}
data={service.spec.ports} data={service.spec.ports}