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..d573be5cca --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/IngressClassReferences.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' +import { IngressRenderer } from './IngressRenderer' +import { IngressClassRenderer } from './IngressClassRenderer' +import { ingressClassParametersResourceRef } from '../../../utils/ingress-class-references' + +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() + }) +}) + +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 }) + } + }) +}) 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, + } +} diff --git a/packages/k8s-ui/src/utils/navigation.test.ts b/packages/k8s-ui/src/utils/navigation.test.ts index b1b413edb6..5fb6ba2b97 100644 --- a/packages/k8s-ui/src/utils/navigation.test.ts +++ b/packages/k8s-ui/src/utils/navigation.test.ts @@ -305,6 +305,19 @@ 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') + // 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', () => { 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..6140770517 100644 --- a/packages/k8s-ui/src/utils/navigation.ts +++ b/packages/k8s-ui/src/utils/navigation.ts @@ -41,6 +41,10 @@ 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', } const BUILTIN_GROUP_KIND_TO_PLURAL: Record = { @@ -54,6 +58,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().