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:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user