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
1 change: 0 additions & 1 deletion internal/server/lookup_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ var lookupAssignment = regexp.MustCompile(`const\s+([A-Za-z_$][\w$]*)\s*=\s*` +
//
// This set may SHRINK. It must never GROW.
var lookupErrorBaseline = map[string]string{
"ServiceRenderer.tsx :: data: endpointSlices, isLoading: endpointSlicesLoading": "pre-existing: endpoint slices behind the Service Endpoints section",
"CNPGDeclarativeRenderer.tsx :: data": "the name resolver degrades to plain text instead of a link, " +
"which is its documented fallback; the list beside it carries its own error note",
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// @vitest-environment jsdom
import {renderToStaticMarkup} from 'react-dom/server'
import {expect,it} from 'vitest'
import {EndpointSliceRenderer} from './EndpointSliceRenderer'
it('displays an unverified owner name and UID without a forward navigation link',()=>{
const html=renderToStaticMarkup(<EndpointSliceRenderer data={{apiVersion:'discovery.k8s.io/v1',kind:'EndpointSlice',metadata:{name:'slice',namespace:'app',ownerReferences:[{apiVersion:'v1',kind:'Service',name:'old',uid:'deleted-owner'}]}}} onNavigate={()=>{}} />)
expect(html).toContain('Declared Owner Service');expect(html).toContain('deleted-owner');expect([...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('button')].some(button => button.textContent === 'old')).toBe(false)
})
it('retains standard label navigation independently from owner metadata',()=>{
const html=renderToStaticMarkup(<EndpointSliceRenderer data={{apiVersion:'discovery.k8s.io/v1',kind:'EndpointSlice',metadata:{name:'slice',namespace:'app',labels:{'kubernetes.io/service-name':'published'}}}} onNavigate={()=>{}} />)
expect(html).toContain('published');expect([...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('button')].some(button => button.textContent === 'published')).toBe(true)
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { clsx } from 'clsx'
import { Section, PropertyList, Property } from '../../ui/drawer-components'
import { Badge } from '../../ui/Badge'
import type { ResourceRef } from '../../../types'
import { endpointSliceServiceAssociation } from '../../../utils/endpoint-slices'

interface EndpointSliceRendererProps {
data: any
Expand All @@ -25,10 +26,10 @@ function endpointTargetLabel(endpoint: any): string | null {

export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendererProps) {
const metadata = data.metadata || {}
const labels = metadata.labels || {}
const endpoints = data.endpoints || []
const ports = data.ports || []
const serviceName = labels['kubernetes.io/service-name']
const serviceAssociation = endpointSliceServiceAssociation(data)
const serviceName = serviceAssociation?.name
Comment thread
nadaverell marked this conversation as resolved.
const readyCount = endpoints.filter(isEndpointReady).length
const addresses = endpointAddressCount(endpoints)

Expand All @@ -42,18 +43,19 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere
<Property label="Ports" value={ports.length} />
{serviceName && (
<Property
label="Service"
value={onNavigate ? (
label={serviceAssociation?.source === 'ownerReference' ? 'Declared Owner Service' : 'Service'}
value={onNavigate && serviceAssociation?.source === 'label' ? (
<button
type="button"
className="text-sm text-accent-text hover:underline font-medium"
onClick={() => onNavigate({ kind: 'Service', namespace: metadata.namespace, name: serviceName })}
onClick={() => onNavigate({ kind: 'Service', group: '', namespace: metadata.namespace, name: serviceName })}
>
{serviceName}
</button>
) : serviceName}
/>
)}
{serviceAssociation?.source === 'ownerReference' && <Property label="Owner UID" value={serviceAssociation.uid} />}
</PropertyList>
</Section>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { expect, it } from 'vitest'
import { ServiceRenderer } from './ServiceRenderer'
const data = { metadata: { name: 'web', namespace: 'team' }, spec: { selector: { app: 'web' } } }
const props = { data, onCopy: () => {}, copied: null }
it('renders queried slices for selected Services without inferring health from template selection', () => {
const html = renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[{ metadata: { name: 'published' }, endpoints: [{ addresses: ['10.0.0.1'], conditions: { ready: false } }] }]} />)
expect(html).toContain('EndpointSlices')
expect(html).toContain('published')
expect(html).toContain('0/1')
})
it('separates unavailable, loading, ready-empty and unprovided inventory', () => {
expect(renderToStaticMarkup(<ServiceRenderer {...props} />)).not.toContain('EndpointSlices')
const error = renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[]} endpointSlicesError="Read denied" />)
expect(error).toContain('EndpointSlice inventory unavailable')
expect(error).not.toContain('No EndpointSlices found')
expect(renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlicesLoading />)).toContain('Loading EndpointSlices')
expect(renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[]} />)).toContain('No EndpointSlices found')
})
it('does not show slice inventory for ExternalName even when supplied', () => {
expect(renderToStaticMarkup(<ServiceRenderer {...props} data={{ ...data, spec: { type: 'ExternalName', externalName: 'example.org' } }} endpointSlicesEnabled endpointSlices={[]} />)).not.toContain('EndpointSlices')
})

it('marks unlabeled owned slices without describing them as published endpoints', () => {
const owned = { apiVersion: 'discovery.k8s.io/v1', kind: 'EndpointSlice', metadata: { namespace: 'team', name: 'owned', ownerReferences: [{ apiVersion: 'v1', kind: 'Service', name: 'web', uid: 'web-now' }] } }
const html = renderToStaticMarkup(<ServiceRenderer {...props} endpointSlicesEnabled endpointSlices={[owned]} />)
expect(html).toContain('Owner reference only')
expect(html).toContain('ownership does not establish published Service endpoints')
})

it('filters raw namespace inventory in the shared renderer, independently from the host label set', () => {
const owned = { apiVersion:'discovery.k8s.io/v1',kind:'EndpointSlice',metadata:{name:'owned',namespace:'team',ownerReferences:[{apiVersion:'v1',kind:'Service',name:'web',uid:'web-now'}]} }
const replaced = {...owned,metadata:{...owned.metadata,name:'replaced',ownerReferences:[{apiVersion:'v1',kind:'Service',name:'web',uid:'web-before'}]}}
const html=renderToStaticMarkup(<ServiceRenderer {...props} data={{...data,metadata:{...data.metadata,uid:'web-now'}}} endpointSlicesEnabled endpointSlices={[]} endpointSliceInventory={[owned,replaced]} />)
expect(html).toContain('owned');expect(html).not.toContain('replaced')
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type ReactNode } from 'react'
import { useMemo, type ReactNode } from 'react'
import { Globe, Clock, Radio } from 'lucide-react'
import { Section, PropertyList, Property, KeyValueBadgeList, CopyHandler, AlertBanner } from '../../ui/drawer-components'
import { endpointSliceServiceAssociation, endpointSliceMatchesService } from '../../../utils/endpoint-slices'
import type { ResourceRef } from '../../../types'

export interface ServicePortRenderProps {
Expand All @@ -17,7 +18,10 @@ interface ServiceRendererProps {
onCopy: CopyHandler
copied: string | null
endpointSlices?: any[]
endpointSliceInventory?: any[]
endpointSlicesLoading?: boolean
endpointSlicesEnabled?: boolean
endpointSlicesError?: string
onNavigate?: (ref: ResourceRef) => void
renderPortAction?: (props: ServicePortRenderProps) => ReactNode
renderPortPanel?: (props: ServicePortRenderProps) => ReactNode
Expand All @@ -38,7 +42,13 @@ function endpointSliceReadyClass(ready: number, total: number): string {
return 'status-unhealthy'
}

export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, onNavigate, renderPortAction, renderPortPanel }: ServiceRendererProps) {
export function ServiceRenderer({ data, onCopy, copied, endpointSlices: suppliedEndpointSlices, endpointSliceInventory, endpointSlicesLoading, endpointSlicesEnabled, endpointSlicesError, onNavigate, renderPortAction, renderPortPanel }: ServiceRendererProps) {
// The host passes raw namespace inventory; association semantics stay inside
// the shared UI. Older published consumers only know endpointSlices, which
// continues to carry their existing label-filtered set.
const endpointSlices = useMemo(() => endpointSliceInventory
? endpointSliceInventory.filter(slice => endpointSliceMatchesService(slice, data))
: suppliedEndpointSlices, [endpointSliceInventory, suppliedEndpointSlices, data])
const spec = data.spec || {}
const ports = spec.ports || []
const lbIngress = data.status?.loadBalancer?.ingress || []
Expand Down Expand Up @@ -111,17 +121,20 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint
</Section>
)}

{hasNoSelector && !isExternalName && (
<Section title="EndpointSlices" icon={Radio}>
{!isExternalName && (hasNoSelector || endpointSlicesEnabled) && (
Comment thread
nadaverell marked this conversation as resolved.
<Section title="EndpointSlices" icon={Radio} defaultExpanded>
{endpointSlicesLoading ? (
<div className="text-sm text-theme-text-tertiary">Loading EndpointSlices…</div>
) : endpointSlicesError ? (
<AlertBanner variant="warning" title="EndpointSlice inventory unavailable" message={endpointSlicesError} />
) : endpointSlices && endpointSlices.length > 0 ? (
<div className="space-y-2">
{endpointSlices.map((slice: any) => {
const sliceName = slice.metadata?.name
const endpoints = slice.endpoints || []
const ready = endpointSliceReadyCount(slice)
const addresses = endpointSliceAddressCount(slice)
const ownerOnly = endpointSliceServiceAssociation(slice)?.source === 'ownerReference'
return (
<button
key={slice.metadata?.uid || sliceName}
Expand All @@ -138,6 +151,7 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint
<div className="min-w-0">
<div className="text-sm font-medium text-theme-text-primary truncate">{sliceName}</div>
<div className="text-xs text-theme-text-tertiary mt-0.5">{slice.addressType || 'Unknown'} address type</div>
{ownerOnly && <div className="text-xs text-theme-text-tertiary mt-0.5" title="No kubernetes.io/service-name label; ownership does not establish published Service endpoints.">Owner reference only</div>}
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5 shrink-0">
<span className={`badge-sm ${endpointSliceReadyClass(ready, endpoints.length)}`}>{ready}/{endpoints.length} ready</span>
Expand Down
28 changes: 28 additions & 0 deletions packages/k8s-ui/src/utils/endpoint-slices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, it } from 'vitest'
import { endpointSliceMatchesService, endpointSliceServiceAssociation } from './endpoint-slices'
const service = { metadata: { namespace: 'team', name: 'web', uid: 'web-now' } }
const owner = (name='web', uid='web-now', controller=false) => ({ apiVersion: 'v1', kind: 'Service', name, uid, controller })
const slice = (metadata: object) => ({ apiVersion: 'discovery.k8s.io/v1', kind: 'EndpointSlice', metadata: { namespace: 'team', ...metadata } })

it('uses the standard label as primary, independently of metadata ownership', () => {
const value = slice({ labels: { 'kubernetes.io/service-name': 'web' }, ownerReferences: [owner('other', 'old', true)] })
expect(endpointSliceMatchesService(value, service)).toBe(true)
expect(endpointSliceServiceAssociation(value)?.source).toBe('label')
expect(endpointSliceMatchesService(slice({ labels: { 'kubernetes.io/service-name': 'other' }, ownerReferences: [owner()] }), service)).toBe(false)
expect(endpointSliceMatchesService(slice({ namespace: 'foreign', labels: { 'kubernetes.io/service-name': 'web' } }), service)).toBe(false)
})

it('resolves unambiguous exact core owners and rejects observed replacement UIDs', () => {
expect(endpointSliceMatchesService(slice({ ownerReferences: [owner()] }), service)).toBe(true)
expect(endpointSliceMatchesService(slice({ ownerReferences: [owner('web', 'deleted')] }), service)).toBe(false)
expect(endpointSliceServiceAssociation(slice({ ownerReferences: [{ ...owner(), apiVersion: 'custom.example.io/v1' }] }))).toBeNull()
expect(endpointSliceServiceAssociation(slice({ ownerReferences: [owner(), owner('other','other-now')] }))).toBeNull()
expect(endpointSliceMatchesService(slice({ ownerReferences: [owner('other','other-now'), owner('web','web-now',true)] }), service)).toBe(true)
expect(endpointSliceMatchesService(slice({ ownerReferences: [owner(), owner()] }), service)).toBe(true)
})

it('does not interpret non-EndpointSlice API groups or empty source identity', () => {
expect(endpointSliceServiceAssociation({ ...slice({ labels: { 'kubernetes.io/service-name': 'web' } }), apiVersion: 'custom.example.io/v1' })).toBeNull()
expect(endpointSliceServiceAssociation(slice({ namespace: '', ownerReferences: [owner()] }))).toBeNull()
expect(endpointSliceServiceAssociation(slice({}))).toBeNull()
})
45 changes: 45 additions & 0 deletions packages/k8s-ui/src/utils/endpoint-slices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ResourceRef } from '../types'
import { apiVersionToGroup } from './navigation'

interface ServiceMetadata {
namespace?: string
name?: string
uid?: string
}
interface EndpointSliceIdentity {
apiVersion?: string
kind?: string
metadata?: ServiceMetadata & {
labels?: Record<string, string>
ownerReferences?: Array<{ apiVersion: string; kind: string; name: string; uid: string; controller?: boolean }>
}
}

export interface EndpointSliceServiceAssociation extends ResourceRef {
source: 'label' | 'ownerReference'
uid?: string
}

/** Labels define the Service's published slice set. Exact core owner metadata
* can identify an unlabeled slice, but ambiguous owners establish no association.
*/
export function endpointSliceServiceAssociation(slice: EndpointSliceIdentity): EndpointSliceServiceAssociation | null {
const metadata = slice.metadata
if (slice.kind !== 'EndpointSlice' || apiVersionToGroup(slice.apiVersion) !== 'discovery.k8s.io' || !metadata?.namespace) return null
const label = metadata.labels?.['kubernetes.io/service-name']
if (label) return { kind: 'Service', group: '', namespace: metadata.namespace, name: label, source: 'label' }
const owners = (metadata.ownerReferences ?? []).filter(ref => ref.apiVersion === 'v1' && ref.kind === 'Service' && ref.name && ref.uid)
const controllers = owners.filter(ref => ref.controller === true)
const candidates = controllers.length ? controllers : owners
const distinct = new Map(candidates.map(ref => [`${ref.name}/${ref.uid}`, ref]))
if (distinct.size !== 1) return null
const owner = distinct.values().next().value!
return { kind: 'Service', group: '', namespace: metadata.namespace, name: owner.name, source: 'ownerReference', uid: owner.uid }
}

export function endpointSliceMatchesService(slice: EndpointSliceIdentity, service: { metadata?: ServiceMetadata }): boolean {
const association = endpointSliceServiceAssociation(slice)
const metadata = service.metadata
return !!association && !!metadata && association.namespace === metadata.namespace && association.name === metadata.name
&& (association.source !== 'ownerReference' || !metadata.uid || association.uid === metadata.uid)
}
33 changes: 33 additions & 0 deletions web/src/components/resources/renderers/ServiceRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, expect, it, vi } from 'vitest'
import { ServiceRenderer } from './ServiceRenderer'
const mock = vi.hoisted(() => ({ query: {} as any, reads: [] as any[] }))
vi.mock('../../../api/client', () => ({ useResources: (...args: any[]) => { mock.reads.push(args); return mock.query } }))
vi.mock('../../../contexts/CapabilitiesContext', () => ({ useNamespacedCapabilities: () => ({ canPortForward: false }), useIsLocalDeployment: () => true }))
vi.mock('../../portforward/PortForwardButton', () => ({ PortForwardInlineButton: () => null }))
vi.mock('../../curl/ServiceCurlButton', () => ({ CurlButton: () => null, CurlPanel: () => null, isHttpishPort: () => false, defaultScheme: () => '', defaultPathForPort: () => '' }))
const service = { apiVersion: 'v1', kind: 'Service', metadata: { name: 'web', namespace: 'team', uid: 'web-now' }, spec: { selector: { app: 'web' } } }
const slice = (name: string, metadata: object) => ({ apiVersion: 'discovery.k8s.io/v1', kind: 'EndpointSlice', metadata: { namespace: 'team', name, ...metadata } })
const render = (data: Parameters<typeof ServiceRenderer>[0]['data'] = service) => renderToStaticMarkup(<ServiceRenderer data={data} onCopy={() => {}} copied={null} />)
beforeEach(() => { mock.query={};mock.reads=[] })
it('queries slices for selected Services and includes exact label or owner associations', () => {
mock.query={ data:[
slice('labeled',{labels:{'kubernetes.io/service-name':'web'}}),
slice('owned',{ownerReferences:[{apiVersion:'v1',kind:'Service',name:'web',uid:'web-now'}]}),
slice('other',{labels:{'kubernetes.io/service-name':'different'}}),
slice('replaced',{ownerReferences:[{apiVersion:'v1',kind:'Service',name:'web',uid:'web-deleted'}]}),
] }
const html=render()
expect(mock.reads[0]).toEqual(['endpointslices','team','discovery.k8s.io',{enabled:true,refetchInterval:30000}])
expect(html).toContain('labeled');expect(html).toContain('owned');expect(html).not.toContain('replaced');expect(html).not.toContain('different')
})
it('exposes inventory read failures rather than empty results', () => {
mock.query={error:new Error('Read denied'),data:[]}
const html=render()
expect(html).toContain('EndpointSlice inventory unavailable');expect(html).not.toContain('No EndpointSlices found')
})
it('disables EndpointSlice inventory for ExternalName', () => {
const data={...service,spec:{type:'ExternalName',externalName:'example.org'}}
const html=render(data)
expect(mock.reads[0][3].enabled).toBe(false);expect(html).not.toContain('EndpointSlices')
})
Loading
Loading