diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f710a26 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/src/api/k8s.ts b/src/api/k8s.ts index f451bcb..f851008 100644 --- a/src/api/k8s.ts +++ b/src/api/k8s.ts @@ -210,7 +210,6 @@ export function isControlPlaneNode(node: KubeVipNode): boolean { export function getNodeVipLabel(node: KubeVipNode): string | undefined { const labels = node.metadata.labels ?? {}; for (const [key, value] of Object.entries(labels)) { - if (key.startsWith('kube-vip.io/has-ip=')) return value; if (key === 'kube-vip.io/has-ip') return value; } return undefined; @@ -351,8 +350,8 @@ export function parseIPPools(data: Record | undefined): IPPool[] const type = poolName.startsWith('range-') ? 'range' : poolName.startsWith('cidr-') - ? 'cidr' - : 'unknown'; + ? 'cidr' + : 'unknown'; pools.push({ name: poolName, type, diff --git a/src/components/ConfigPage.tsx b/src/components/ConfigPage.tsx index c1e77ba..65a4679 100644 --- a/src/components/ConfigPage.tsx +++ b/src/components/ConfigPage.tsx @@ -122,15 +122,15 @@ export default function ConfigPage() { { name: 'Updated', value: String(daemonSetStatus.updatedNumberScheduled ?? 0) }, ...(daemonSetStatus.numberMisscheduled ? [ - { - name: 'Misscheduled', - value: ( - - {daemonSetStatus.numberMisscheduled} - - ), - }, - ] + { + name: 'Misscheduled', + value: ( + + {daemonSetStatus.numberMisscheduled} + + ), + }, + ] : []), ]} /> diff --git a/src/components/NodesPage.tsx b/src/components/NodesPage.tsx index a2f0243..ecb0285 100644 --- a/src/components/NodesPage.tsx +++ b/src/components/NodesPage.tsx @@ -20,6 +20,7 @@ import { getNodeVipLabel, isControlPlaneNode, isNodeReady, + phaseToStatus, } from '../api/k8s'; import { useKubeVipContext } from '../api/KubeVipDataContext'; @@ -83,7 +84,7 @@ export default function NodesPage() { const pod = podByNode.get(n.metadata.name); if (!pod) return '—'; return ( - + {pod.status?.phase ?? 'Unknown'} ); @@ -127,7 +128,7 @@ export default function NodesPage() { const pod = podByNode.get(n.metadata.name); if (!pod) return '—'; return ( - + {pod.status?.phase ?? 'Unknown'} ); diff --git a/src/components/OverviewPage.tsx b/src/components/OverviewPage.tsx index 7cd41af..bf50964 100644 --- a/src/components/OverviewPage.tsx +++ b/src/components/OverviewPage.tsx @@ -55,22 +55,18 @@ export default function OverviewPage() { kubeVipConfig['bgp_enable'] === 'true' ? 'BGP' : kubeVipConfig['vip_arp'] === 'true' - ? 'ARP' - : kubeVipPods.length > 0 - ? 'Unknown' - : '—'; + ? '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 cpLease = leases.find(l => l.metadata.name.startsWith('plndr-cp-lock')); + const svcLease = leases.find(l => l.metadata.name.startsWith('plndr-svcs-lock')); const leaderNode = cpLease?.spec?.holderIdentity ?? svcLease?.spec?.holderIdentity ?? '—'; return ( @@ -153,27 +149,27 @@ export default function OverviewPage() { }, ...(daemonSetStatus ? [ - { - name: 'DaemonSet', - value: `${daemonSetStatus.numberReady ?? 0}/${ - daemonSetStatus.desiredNumberScheduled ?? 0 - } ready`, - }, - ] + { + name: 'DaemonSet', + value: `${daemonSetStatus.numberReady ?? 0}/${ + daemonSetStatus.desiredNumberScheduled ?? 0 + } ready`, + }, + ] : []), ...(cloudProviderPods.length > 0 ? [ - { - name: 'Cloud Provider', - value: ( - - {cloudProviderPods.length} pod(s) - - ), - }, - ] + { + name: 'Cloud Provider', + value: ( + + {cloudProviderPods.length} pod(s) + + ), + }, + ] : []), ]} /> @@ -188,11 +184,11 @@ export default function OverviewPage() { { name: 'kube-vip Managed', value: String(kubeVipManaged.length) }, ...(egressEnabled.length > 0 ? [ - { - name: 'Egress Enabled', - value: String(egressEnabled.length), - }, - ] + { + name: 'Egress Enabled', + value: String(egressEnabled.length), + }, + ] : []), { name: 'IP Pools', value: String(ipPools.length) }, { name: 'Leader Election Leases', value: String(leases.length) }, diff --git a/src/components/ServiceDetailSection.tsx b/src/components/ServiceDetailSection.tsx index 52bf184..46e34e9 100644 --- a/src/components/ServiceDetailSection.tsx +++ b/src/components/ServiceDetailSection.tsx @@ -68,21 +68,21 @@ export default function ServiceDetailSection({ resource }: ServiceDetailSectionP ...(vipHost ? [{ name: 'VIP Host Node', value: vipHost }] : []), ...(isEgressEnabled(svc) ? [ - { - name: 'Egress', - value: Enabled, - }, - ] + { + name: 'Egress', + value: Enabled, + }, + ] : []), ...(isServiceIgnored(svc) ? [ - { - name: 'Ignored', - value: ( - kube-vip is ignoring this service - ), - }, - ] + { + name: 'Ignored', + value: ( + kube-vip is ignoring this service + ), + }, + ] : []), ...kubeVipAnnotations .filter(([key]) => key !== ANNOTATION_LOADBALANCER_IPS) diff --git a/src/components/ServicesPage.test.tsx b/src/components/ServicesPage.test.tsx index 1861f53..bfa00fe 100644 --- a/src/components/ServicesPage.test.tsx +++ b/src/components/ServicesPage.test.tsx @@ -76,7 +76,7 @@ describe('ServicesPage', () => { const svc = makeSampleService(); mockContext({ loadBalancerServices: [svc] }); render(); - fireEvent.click(screen.getByLabelText('Close panel backdrop')); + fireEvent.click(screen.getByTestId('panel-backdrop')); expect(mockPush).toHaveBeenCalledWith('/kube-vip/services'); }); diff --git a/src/components/ServicesPage.tsx b/src/components/ServicesPage.tsx index ce84226..5e02d7b 100644 --- a/src/components/ServicesPage.tsx +++ b/src/components/ServicesPage.tsx @@ -13,7 +13,7 @@ import { SimpleTable, StatusLabel, } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { formatAge, @@ -38,7 +38,10 @@ export default function ServicesPage() { ? loadBalancerServices.find(s => `${s.metadata.namespace}/${s.metadata.name}` === selectedName) : null; - const closePanel = () => history.push(location.pathname); + const closePanel = useCallback( + () => history.push(location.pathname), + [history, location.pathname] + ); useEffect(() => { if (!selectedName) return; @@ -47,7 +50,7 @@ export default function ServicesPage() { }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }); + }, [selectedName, closePanel]); if (loading) { return ; @@ -111,7 +114,7 @@ export default function ServicesPage() { { label: 'kube-vip', getter: s => ( - + {isKubeVipService(s) ? 'Yes' : '—'} ), @@ -130,8 +133,9 @@ export default function ServicesPage() { {selectedService && ( <>
p.protocol ?? 'TCP' }, ...(service.spec.ports?.some((p: { nodePort?: number }) => p.nodePort) ? [ - { - label: 'NodePort', - getter: (p: { nodePort?: number }) => String(p.nodePort ?? '—'), - }, - ] + { + label: 'NodePort', + getter: (p: { nodePort?: number }) => String(p.nodePort ?? '—'), + }, + ] : []), ]} data={service.spec.ports}