From 427db93ed372775a260098f8928b3b037bfe68d6 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 07:54:58 +0300 Subject: [PATCH 1/4] Link Ingress class and correctly scoped controller parameters --- .../renderers/IngressClassReferences.test.tsx | 27 +++++++++++++++++++ .../renderers/IngressClassRenderer.tsx | 13 ++++++--- .../resources/renderers/IngressRenderer.tsx | 8 ++++-- .../shared/ResourceRendererDispatch.tsx | 2 +- .../src/utils/ingress-class-references.ts | 22 +++++++++++++++ 5 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 packages/k8s-ui/src/components/resources/renderers/IngressClassReferences.test.tsx create mode 100644 packages/k8s-ui/src/utils/ingress-class-references.ts diff --git a/packages/k8s-ui/src/components/resources/renderers/IngressClassReferences.test.tsx b/packages/k8s-ui/src/components/resources/renderers/IngressClassReferences.test.tsx new file mode 100644 index 0000000000..69f8b259fd --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/IngressClassReferences.test.tsx @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' +import { IngressRenderer } from './IngressRenderer' +import { IngressClassRenderer } from './IngressClassRenderer' +import { ingressClassParametersResourceRef } from '../../../utils/ingress-class-references' + +it('uses the parameters reference API group and explicit scope without an apiVersion guess',()=>{ + expect(ingressClassParametersResourceRef({kind:'IngressClassParams',apiGroup:'elbv2.k8s.aws',name:'shared'})).toEqual({kind:'IngressClassParams',group:'elbv2.k8s.aws',namespace:'',name:'shared'}) + expect(ingressClassParametersResourceRef({kind:'ConfigMap',scope:'Namespace',namespace:'controller',name:'settings'})).toEqual({kind:'ConfigMap',group:'',namespace:'controller',name:'settings'}) + expect(ingressClassParametersResourceRef({kind:'ConfigMap',scope:'Namespace',name:'settings'})).toBeNull() +}) +it('clicks through explicit Ingress class and both parameter scopes, while legacy annotations remain text',async()=>{ + const onNavigate=vi.fn();const node=document.createElement('div');document.body.append(node);const root=createRoot(node) + const click=async(name:string)=>{const b=Array.from(node.querySelectorAll('button')).find(b=>b.textContent===name);expect(b).toBeDefined();await act(async()=>{b!.click()})} + try{ + await act(async()=>{root.render()}) + await click('public');expect(onNavigate).toHaveBeenLastCalledWith({kind:'ingressclasses',group:'networking.k8s.io',namespace:'',name:'public'}) + await act(async()=>{root.render()}) + expect(node.textContent).toContain('old-controller');expect(Array.from(node.querySelectorAll('button')).some(b=>b.textContent==='old-controller')).toBe(false) + for(const parameters of [{name:'shared',kind:'IngressClassParams',apiGroup:'elbv2.k8s.aws'},{name:'settings',kind:'ConfigMap',scope:'Namespace' as const,namespace:'controller'}]){ + await act(async()=>{root.render()}) + await click(parameters.name);expect(onNavigate).toHaveBeenLastCalledWith(ingressClassParametersResourceRef(parameters)) + } + }finally{await act(async()=>{root.unmount()});node.remove()} +}) diff --git a/packages/k8s-ui/src/components/resources/renderers/IngressClassRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/IngressClassRenderer.tsx index cdd8706790..f447bfa98e 100644 --- a/packages/k8s-ui/src/components/resources/renderers/IngressClassRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/IngressClassRenderer.tsx @@ -1,14 +1,18 @@ import { Globe } from 'lucide-react' import { clsx } from 'clsx' -import { Section, PropertyList, Property } from '../../ui/drawer-components' +import { Section, PropertyList, Property, ResourceLink } from '../../ui/drawer-components' +import type { ResourceRef } from '../../../types' +import { ingressClassParametersResourceRef } from '../../../utils/ingress-class-references' import { BADGE_INACTIVE } from '../../../utils/badge-colors' interface IngressClassRendererProps { data: any + onNavigate?: (ref: ResourceRef) => void } -export function IngressClassRenderer({ data }: IngressClassRendererProps) { +export function IngressClassRenderer({ data, onNavigate }: IngressClassRendererProps) { const spec = data.spec || {} + const parametersRef = spec.parameters ? ingressClassParametersResourceRef(spec.parameters) : null const annotations = data.metadata?.annotations || {} const isDefault = annotations['ingressclass.kubernetes.io/is-default-class'] === 'true' @@ -39,7 +43,10 @@ export function IngressClassRenderer({ data }: IngressClassRendererProps) { {spec.parameters.apiGroup && } - + + : spec.parameters.name + } /> {spec.parameters.namespace && } {spec.parameters.scope && } diff --git a/packages/k8s-ui/src/components/resources/renderers/IngressRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/IngressRenderer.tsx index 3ef9c12f34..d0e3740094 100644 --- a/packages/k8s-ui/src/components/resources/renderers/IngressRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/IngressRenderer.tsx @@ -1,9 +1,10 @@ import { Globe, Shield, Clock } from 'lucide-react' +import type { ResourceRef } from '../../../types' import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components' interface IngressRendererProps { data: any - onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void + onNavigate?: (ref: ResourceRef) => void } export function IngressRenderer({ data, onNavigate }: IngressRendererProps) { @@ -42,7 +43,10 @@ export function IngressRenderer({ data, onNavigate }: IngressRendererProps) {
- + + : data.metadata?.annotations?.['kubernetes.io/ingress.class'] + } /> {lbIngress.length > 0 && ( )} diff --git a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx index 68969915d1..6f5d0be65f 100644 --- a/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx +++ b/packages/k8s-ui/src/components/shared/ResourceRendererDispatch.tsx @@ -978,7 +978,7 @@ export function ResourceRendererDispatch({ {kind === 'authorizationpolicies' && } {kind === 'mutatingwebhookconfigurations' && } {kind === 'validatingwebhookconfigurations' && } - {kind === 'ingressclasses' && } + {kind === 'ingressclasses' && } {kind === 'priorityclasses' && } {kind === 'runtimeclasses' && } {kind === 'leases' && } diff --git a/packages/k8s-ui/src/utils/ingress-class-references.ts b/packages/k8s-ui/src/utils/ingress-class-references.ts new file mode 100644 index 0000000000..ba42340765 --- /dev/null +++ b/packages/k8s-ui/src/utils/ingress-class-references.ts @@ -0,0 +1,22 @@ +import type { ResourceRef } from '../types' + +interface IngressClassParametersReference { + name: string + kind: string + apiGroup?: string + scope?: 'Cluster' | 'Namespace' + namespace?: string +} + +/** IngressClass parameters default to cluster scope and the core API group. + * A namespaced reference requires its own namespace; the class has none. + */ +export function ingressClassParametersResourceRef(ref: IngressClassParametersReference): ResourceRef | null { + if (ref.scope === 'Namespace' && !ref.namespace) return null + return { + kind: ref.kind, + group: ref.apiGroup ?? '', + namespace: ref.scope === 'Namespace' ? ref.namespace! : '', + name: ref.name, + } +} From 25b81fe0e72b3532e806dec328534d703563bcf6 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 12:09:24 +0300 Subject: [PATCH 2/4] Rewrite Ingress class reference test in renderer-test style --- .../renderers/IngressClassReferences.test.tsx | 61 +++++++++++++------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/IngressClassReferences.test.tsx b/packages/k8s-ui/src/components/resources/renderers/IngressClassReferences.test.tsx index 69f8b259fd..d573be5cca 100644 --- a/packages/k8s-ui/src/components/resources/renderers/IngressClassReferences.test.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/IngressClassReferences.test.tsx @@ -1,27 +1,52 @@ // @vitest-environment jsdom import { act } from 'react' import { createRoot } from 'react-dom/client' -import { expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { IngressRenderer } from './IngressRenderer' import { IngressClassRenderer } from './IngressClassRenderer' import { ingressClassParametersResourceRef } from '../../../utils/ingress-class-references' -it('uses the parameters reference API group and explicit scope without an apiVersion guess',()=>{ - expect(ingressClassParametersResourceRef({kind:'IngressClassParams',apiGroup:'elbv2.k8s.aws',name:'shared'})).toEqual({kind:'IngressClassParams',group:'elbv2.k8s.aws',namespace:'',name:'shared'}) - expect(ingressClassParametersResourceRef({kind:'ConfigMap',scope:'Namespace',namespace:'controller',name:'settings'})).toEqual({kind:'ConfigMap',group:'',namespace:'controller',name:'settings'}) - expect(ingressClassParametersResourceRef({kind:'ConfigMap',scope:'Namespace',name:'settings'})).toBeNull() +describe('ingressClassParametersResourceRef', () => { + it('defaults to the core group and cluster scope, and requires a namespace for namespaced parameters', () => { + expect(ingressClassParametersResourceRef({ kind: 'IngressClassParams', apiGroup: 'elbv2.k8s.aws', name: 'shared' })) + .toEqual({ kind: 'IngressClassParams', group: 'elbv2.k8s.aws', namespace: '', name: 'shared' }) + expect(ingressClassParametersResourceRef({ kind: 'ConfigMap', scope: 'Namespace', namespace: 'controller', name: 'settings' })) + .toEqual({ kind: 'ConfigMap', group: '', namespace: 'controller', name: 'settings' }) + expect(ingressClassParametersResourceRef({ kind: 'ConfigMap', scope: 'Namespace', name: 'settings' })).toBeNull() + }) }) -it('clicks through explicit Ingress class and both parameter scopes, while legacy annotations remain text',async()=>{ - const onNavigate=vi.fn();const node=document.createElement('div');document.body.append(node);const root=createRoot(node) - const click=async(name:string)=>{const b=Array.from(node.querySelectorAll('button')).find(b=>b.textContent===name);expect(b).toBeDefined();await act(async()=>{b!.click()})} - try{ - await act(async()=>{root.render()}) - await click('public');expect(onNavigate).toHaveBeenLastCalledWith({kind:'ingressclasses',group:'networking.k8s.io',namespace:'',name:'public'}) - await act(async()=>{root.render()}) - expect(node.textContent).toContain('old-controller');expect(Array.from(node.querySelectorAll('button')).some(b=>b.textContent==='old-controller')).toBe(false) - for(const parameters of [{name:'shared',kind:'IngressClassParams',apiGroup:'elbv2.k8s.aws'},{name:'settings',kind:'ConfigMap',scope:'Namespace' as const,namespace:'controller'}]){ - await act(async()=>{root.render()}) - await click(parameters.name);expect(onNavigate).toHaveBeenLastCalledWith(ingressClassParametersResourceRef(parameters)) - } - }finally{await act(async()=>{root.unmount()});node.remove()} + +describe('Ingress class navigation', () => { + it('links the Ingress class and both parameter scopes, but leaves the legacy annotation as text', async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + const container = document.createElement('div') + const root = createRoot(container) + const onNavigate = vi.fn() + const click = async (name: string) => { + const button = [...container.querySelectorAll('button')].find(button => button.textContent === name) + expect(button).toBeDefined() + await act(async () => button!.click()) + } + try { + await act(async () => root.render()) + await click('public') + expect(onNavigate).toHaveBeenLastCalledWith({ kind: 'ingressclasses', group: 'networking.k8s.io', namespace: '', name: 'public' }) + + await act(async () => root.render()) + expect(container.textContent).toContain('old-controller') + expect([...container.querySelectorAll('button')].some(button => button.textContent === 'old-controller')).toBe(false) + + for (const parameters of [ + { name: 'shared', kind: 'IngressClassParams', apiGroup: 'elbv2.k8s.aws' }, + { name: 'settings', kind: 'ConfigMap', scope: 'Namespace' as const, namespace: 'controller' }, + ]) { + await act(async () => root.render()) + await click(parameters.name) + expect(onNavigate).toHaveBeenLastCalledWith(ingressClassParametersResourceRef(parameters)) + } + } finally { + await act(async () => root.unmount()) + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false }) + } + }) }) From d40fde11294c2f7a0be52cdc5948e34d074318c0 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 12:16:37 +0300 Subject: [PATCH 3/4] Resolve AWS IngressClassParams plural without discovery IngressClassParams is singular and plural at once, so hosts that navigate without loaded discovery (Radar Hub's fleet view, Radar's cold direct-URL load) pluralized the parameter link to the nonexistent ingressclassparamses. --- packages/k8s-ui/src/utils/navigation.test.ts | 10 ++++++++++ packages/k8s-ui/src/utils/navigation.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/packages/k8s-ui/src/utils/navigation.test.ts b/packages/k8s-ui/src/utils/navigation.test.ts index b1b413edb6..7aed6e28b3 100644 --- a/packages/k8s-ui/src/utils/navigation.test.ts +++ b/packages/k8s-ui/src/utils/navigation.test.ts @@ -305,6 +305,16 @@ describe('refToSelectedResource', () => { }) }) + test('resolves AWS IngressClassParams without discovery', () => { + expect(refToSelectedResource({ kind: 'IngressClassParams', name: 'alb', group: 'elbv2.k8s.aws' })).toEqual({ + kind: 'ingressclassparams', + name: 'alb', + namespace: '', + group: 'elbv2.k8s.aws', + }) + expect(knownKindForPluralWithGroup('ingressclassparams', 'elbv2.k8s.aws')).toBe('IngressClassParams') + }) + test('normalizes an omitted namespace for cluster-scoped references', () => { expect(refToSelectedResource({ kind: 'NodePool', name: 'spot' })).toEqual({ kind: 'nodepools', diff --git a/packages/k8s-ui/src/utils/navigation.ts b/packages/k8s-ui/src/utils/navigation.ts index b355930142..a4801e2132 100644 --- a/packages/k8s-ui/src/utils/navigation.ts +++ b/packages/k8s-ui/src/utils/navigation.ts @@ -43,7 +43,10 @@ const BUILTIN_PLURAL_TO_KIND: Record = { networkpolicies: 'NetworkPolicy', } +// IngressClassParams is singular and plural at once, so English pluralization +// cannot recover it in hosts that never load discovery. const BUILTIN_GROUP_KIND_TO_PLURAL: Record = { + 'elbv2.k8s.aws/ingressclassparams': 'ingressclassparams', 'scheduling.k8s.io/podgroup': 'podgroups', 'metrics.k8s.io/podmetrics': 'pods', 'metrics.k8s.io/nodemetrics': 'nodes', @@ -54,6 +57,7 @@ const BUILTIN_GROUP_PLURAL_TO_KIND: Record = { 'metrics.k8s.io/pods': 'PodMetrics', 'metrics.k8s.io/nodes': 'NodeMetrics', 'scheduling.k8s.io/podgroups': 'PodGroup', + 'elbv2.k8s.aws/ingressclassparams': 'IngressClassParams', } // Dynamic map built from API discovery — populated by initNavigationMap(). From 13c64136f9a2301d351a2af8a283e32319e3b3a4 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 12:25:25 +0300 Subject: [PATCH 4/4] Map IngressClassParams in the unqualified plural table Hosts without discovery also fold the plural back with pluralToKind, which produced Ingressclassparam and dropped the object's events in Radar Hub. One unqualified entry serves both directions, so the group-qualified Kind entry is no longer needed. --- packages/k8s-ui/src/utils/navigation.test.ts | 3 +++ packages/k8s-ui/src/utils/navigation.ts | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/k8s-ui/src/utils/navigation.test.ts b/packages/k8s-ui/src/utils/navigation.test.ts index 7aed6e28b3..5fb6ba2b97 100644 --- a/packages/k8s-ui/src/utils/navigation.test.ts +++ b/packages/k8s-ui/src/utils/navigation.test.ts @@ -313,6 +313,9 @@ describe('refToSelectedResource', () => { group: 'elbv2.k8s.aws', }) expect(knownKindForPluralWithGroup('ingressclassparams', 'elbv2.k8s.aws')).toBe('IngressClassParams') + // Hosts without discovery fold the plural back to a Kind and re-pluralize it. + expect(pluralToKind('ingressclassparams')).toBe('IngressClassParams') + expect(kindToPlural(pluralToKind('ingressclassparams'))).toBe('ingressclassparams') }) test('normalizes an omitted namespace for cluster-scoped references', () => { diff --git a/packages/k8s-ui/src/utils/navigation.ts b/packages/k8s-ui/src/utils/navigation.ts index a4801e2132..6140770517 100644 --- a/packages/k8s-ui/src/utils/navigation.ts +++ b/packages/k8s-ui/src/utils/navigation.ts @@ -41,12 +41,13 @@ const BUILTIN_PLURAL_TO_KIND: Record = { clusterrolebindings: 'ClusterRoleBinding', serviceaccounts: 'ServiceAccount', networkpolicies: 'NetworkPolicy', + // AWS Load Balancer Controller's IngressClass parameters. The Kind is + // singular and plural at once, so English rules cannot recover it in hosts + // that never load discovery. + ingressclassparams: 'IngressClassParams', } -// IngressClassParams is singular and plural at once, so English pluralization -// cannot recover it in hosts that never load discovery. const BUILTIN_GROUP_KIND_TO_PLURAL: Record = { - 'elbv2.k8s.aws/ingressclassparams': 'ingressclassparams', 'scheduling.k8s.io/podgroup': 'podgroups', 'metrics.k8s.io/podmetrics': 'pods', 'metrics.k8s.io/nodemetrics': 'nodes',