Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<IngressRenderer onNavigate={onNavigate} data={{ metadata: { namespace: 'app', annotations: { 'kubernetes.io/ingress.class': 'old-controller' } }, spec: { ingressClassName: 'public' } }} />))
await click('public')
expect(onNavigate).toHaveBeenLastCalledWith({ kind: 'ingressclasses', group: 'networking.k8s.io', namespace: '', name: 'public' })

await act(async () => root.render(<IngressRenderer onNavigate={onNavigate} data={{ metadata: { annotations: { 'kubernetes.io/ingress.class': 'old-controller' } }, spec: {} }} />))
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(<IngressClassRenderer onNavigate={onNavigate} data={{ spec: { controller: 'example.test/ingress', parameters } }} />))
await click(parameters.name)
expect(onNavigate).toHaveBeenLastCalledWith(ingressClassParametersResourceRef(parameters))
}
} finally {
await act(async () => root.unmount())
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
}
})
})
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -39,7 +43,10 @@ export function IngressClassRenderer({ data }: IngressClassRendererProps) {
<PropertyList>
{spec.parameters.apiGroup && <Property label="API Group" value={spec.parameters.apiGroup} />}
<Property label="Kind" value={spec.parameters.kind} />
<Property label="Name" value={spec.parameters.name} />
<Property label="Name" value={parametersRef
? <ResourceLink {...parametersRef} onNavigate={onNavigate} />
: spec.parameters.name
} />
{spec.parameters.namespace && <Property label="Namespace" value={spec.parameters.namespace} />}
{spec.parameters.scope && <Property label="Scope" value={spec.parameters.scope} />}
</PropertyList>
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -42,7 +43,10 @@ export function IngressRenderer({ data, onNavigate }: IngressRendererProps) {

<Section title="Ingress" icon={Globe}>
<PropertyList>
<Property label="Class" value={spec.ingressClassName || data.metadata?.annotations?.['kubernetes.io/ingress.class']} />
<Property label="Class" value={spec.ingressClassName
? <ResourceLink name={spec.ingressClassName} kind="ingressclasses" group="networking.k8s.io" onNavigate={onNavigate} />
: data.metadata?.annotations?.['kubernetes.io/ingress.class']
} />
{lbIngress.length > 0 && (
<Property label="Address" value={lbIngress[0].ip || lbIngress[0].hostname} />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -978,7 +978,7 @@ export function ResourceRendererDispatch({
{kind === 'authorizationpolicies' && <IstioAuthorizationPolicyRenderer data={data} />}
{kind === 'mutatingwebhookconfigurations' && <WebhookConfigRenderer data={data} isMutating />}
{kind === 'validatingwebhookconfigurations' && <WebhookConfigRenderer data={data} />}
{kind === 'ingressclasses' && <IngressClassRenderer data={data} />}
{kind === 'ingressclasses' && <IngressClassRenderer data={data} onNavigate={onNavigate} />}
{kind === 'priorityclasses' && <PriorityClassRenderer data={data} />}
{kind === 'runtimeclasses' && <RuntimeClassRenderer data={data} />}
{kind === 'leases' && <LeaseRenderer data={data} />}
Expand Down
22 changes: 22 additions & 0 deletions packages/k8s-ui/src/utils/ingress-class-references.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
13 changes: 13 additions & 0 deletions packages/k8s-ui/src/utils/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions packages/k8s-ui/src/utils/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
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<string, string> = {
Expand All @@ -54,6 +58,7 @@ const BUILTIN_GROUP_PLURAL_TO_KIND: Record<string, string> = {
'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().
Expand Down
Loading