From b15638ff8213b011d9dfd5f304dfc26c30e0ecd5 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 07:21:54 +0300 Subject: [PATCH 1/6] Expose EndpointSlice inventories for selected Services with exact associations --- .../renderers/EndpointSliceRenderer.tsx | 4 +- .../renderers/ServiceRenderer.test.tsx | 22 +++++++++ .../resources/renderers/ServiceRenderer.tsx | 8 +++- .../k8s-ui/src/utils/endpoint-slices.test.ts | 28 ++++++++++++ packages/k8s-ui/src/utils/endpoint-slices.ts | 45 +++++++++++++++++++ .../renderers/ServiceRenderer.test.tsx | 33 ++++++++++++++ .../resources/renderers/ServiceRenderer.tsx | 12 ++--- 7 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx create mode 100644 packages/k8s-ui/src/utils/endpoint-slices.test.ts create mode 100644 packages/k8s-ui/src/utils/endpoint-slices.ts create mode 100644 web/src/components/resources/renderers/ServiceRenderer.test.tsx diff --git a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx index 4e4a2a4940..4dadf7a4b7 100644 --- a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx @@ -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 @@ -25,10 +26,9 @@ 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 serviceName = endpointSliceServiceAssociation(data)?.name const readyCount = endpoints.filter(isEndpointReady).length const addresses = endpointAddressCount(endpoints) diff --git a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx new file mode 100644 index 0000000000..e5185c4659 --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx @@ -0,0 +1,22 @@ +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() + expect(html).toContain('EndpointSlices') + expect(html).toContain('published') + expect(html).toContain('0/1') +}) +it('separates unavailable, loading, ready-empty and unprovided inventory', () => { + expect(renderToStaticMarkup()).not.toContain('EndpointSlices') + const error = renderToStaticMarkup() + expect(error).toContain('EndpointSlice inventory unavailable') + expect(error).not.toContain('No EndpointSlices found') + expect(renderToStaticMarkup()).toContain('Loading EndpointSlices') + expect(renderToStaticMarkup()).toContain('No EndpointSlices found') +}) +it('does not show slice inventory for ExternalName even when supplied', () => { + expect(renderToStaticMarkup()).not.toContain('EndpointSlices') +}) diff --git a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx index f2074b260d..e0b648478f 100644 --- a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx @@ -18,6 +18,8 @@ interface ServiceRendererProps { copied: string | null endpointSlices?: any[] endpointSlicesLoading?: boolean + endpointSlicesEnabled?: boolean + endpointSlicesError?: string onNavigate?: (ref: ResourceRef) => void renderPortAction?: (props: ServicePortRenderProps) => ReactNode renderPortPanel?: (props: ServicePortRenderProps) => ReactNode @@ -38,7 +40,7 @@ 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, endpointSlicesLoading, endpointSlicesEnabled, endpointSlicesError, onNavigate, renderPortAction, renderPortPanel }: ServiceRendererProps) { const spec = data.spec || {} const ports = spec.ports || [] const lbIngress = data.status?.loadBalancer?.ingress || [] @@ -111,10 +113,12 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint )} - {hasNoSelector && !isExternalName && ( + {!isExternalName && (hasNoSelector || endpointSlicesEnabled) && (
{endpointSlicesLoading ? (
Loading EndpointSlices…
+ ) : endpointSlicesError ? ( + ) : endpointSlices && endpointSlices.length > 0 ? (
{endpointSlices.map((slice: any) => { diff --git a/packages/k8s-ui/src/utils/endpoint-slices.test.ts b/packages/k8s-ui/src/utils/endpoint-slices.test.ts new file mode 100644 index 0000000000..bb941de2d6 --- /dev/null +++ b/packages/k8s-ui/src/utils/endpoint-slices.test.ts @@ -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() +}) diff --git a/packages/k8s-ui/src/utils/endpoint-slices.ts b/packages/k8s-ui/src/utils/endpoint-slices.ts new file mode 100644 index 0000000000..cc49aed73a --- /dev/null +++ b/packages/k8s-ui/src/utils/endpoint-slices.ts @@ -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 + 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) +} diff --git a/web/src/components/resources/renderers/ServiceRenderer.test.tsx b/web/src/components/resources/renderers/ServiceRenderer.test.tsx new file mode 100644 index 0000000000..23051e25d9 --- /dev/null +++ b/web/src/components/resources/renderers/ServiceRenderer.test.tsx @@ -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=service) => renderToStaticMarkup( {}} 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 as typeof service) + expect(mock.reads[0][3].enabled).toBe(false);expect(html).not.toContain('EndpointSlices') +}) diff --git a/web/src/components/resources/renderers/ServiceRenderer.tsx b/web/src/components/resources/renderers/ServiceRenderer.tsx index 4a69dcc301..28a76e33fa 100644 --- a/web/src/components/resources/renderers/ServiceRenderer.tsx +++ b/web/src/components/resources/renderers/ServiceRenderer.tsx @@ -6,6 +6,7 @@ import { useResources } from '../../../api/client' import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext' import type { ResourceRef } from '../../../types' import { DURATION_DISCLOSURE } from '@skyhook-io/k8s-ui/utils/animation' +import { endpointSliceMatchesService } from '@skyhook-io/k8s-ui/utils/endpoint-slices' interface ServiceRendererProps { data: any @@ -39,18 +40,17 @@ export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRen const shouldLoadEndpointSlices = Boolean( namespace && serviceName && - spec.type !== 'ExternalName' && - (!spec.selector || Object.keys(spec.selector).length === 0) + spec.type !== 'ExternalName' ) - const { data: endpointSlices, isLoading: endpointSlicesLoading } = useResources( + const { data: endpointSlices, isLoading: endpointSlicesLoading, error: endpointSlicesError } = useResources( 'endpointslices', namespace, 'discovery.k8s.io', { enabled: shouldLoadEndpointSlices, refetchInterval: 30000 } ) const matchingEndpointSlices = useMemo( - () => (endpointSlices || []).filter((slice: any) => slice.metadata?.labels?.['kubernetes.io/service-name'] === serviceName), - [endpointSlices, serviceName] + () => (endpointSlices || []).filter((slice: any) => endpointSliceMatchesService(slice, data)), + [endpointSlices, data] ) return ( @@ -60,6 +60,8 @@ export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRen copied={copied} endpointSlices={matchingEndpointSlices} endpointSlicesLoading={endpointSlicesLoading} + endpointSlicesEnabled={shouldLoadEndpointSlices} + endpointSlicesError={endpointSlicesError?.message} onNavigate={onNavigate} renderPortAction={({ port, name, appProtocol, protocol }) => ( <> From 515a8e04f0e2223809471d4274c108713a8f95c4 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 07:27:04 +0300 Subject: [PATCH 2/6] test: type Service renderer inputs according to its props --- .../components/resources/renderers/ServiceRenderer.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/resources/renderers/ServiceRenderer.test.tsx b/web/src/components/resources/renderers/ServiceRenderer.test.tsx index 23051e25d9..5b75385cfe 100644 --- a/web/src/components/resources/renderers/ServiceRenderer.test.tsx +++ b/web/src/components/resources/renderers/ServiceRenderer.test.tsx @@ -8,7 +8,7 @@ vi.mock('../../portforward/PortForwardButton', () => ({ PortForwardInlineButton: 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=service) => renderToStaticMarkup( {}} copied={null} />) +const render = (data: Parameters[0]['data'] = service) => renderToStaticMarkup( {}} copied={null} />) beforeEach(() => { mock.query={};mock.reads=[] }) it('queries slices for selected Services and includes exact label or owner associations', () => { mock.query={ data:[ @@ -28,6 +28,6 @@ it('exposes inventory read failures rather than empty results', () => { }) it('disables EndpointSlice inventory for ExternalName', () => { const data={...service,spec:{type:'ExternalName',externalName:'example.org'}} - const html=render(data as typeof service) + const html=render(data) expect(mock.reads[0][3].enabled).toBe(false);expect(html).not.toContain('EndpointSlices') }) From d339ef3215e1d8bc1f63461db3ab89beaa4ddf12 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 07:30:12 +0300 Subject: [PATCH 3/6] ui: distinguish unlabeled slice ownership from published endpoints --- .../resources/renderers/EndpointSliceRenderer.tsx | 7 ++++--- .../resources/renderers/ServiceRenderer.test.tsx | 7 +++++++ .../src/components/resources/renderers/ServiceRenderer.tsx | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx index 4dadf7a4b7..c27df62ba4 100644 --- a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx @@ -28,7 +28,8 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere const metadata = data.metadata || {} const endpoints = data.endpoints || [] const ports = data.ports || [] - const serviceName = endpointSliceServiceAssociation(data)?.name + const serviceAssociation = endpointSliceServiceAssociation(data) + const serviceName = serviceAssociation?.name const readyCount = endpoints.filter(isEndpointReady).length const addresses = endpointAddressCount(endpoints) @@ -42,12 +43,12 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere {serviceName && ( onNavigate({ kind: 'Service', namespace: metadata.namespace, name: serviceName })} + onClick={() => onNavigate({ kind: 'Service', group: '', namespace: metadata.namespace, name: serviceName })} > {serviceName} diff --git a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx index e5185c4659..d143cc07d4 100644 --- a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx @@ -20,3 +20,10 @@ it('separates unavailable, loading, ready-empty and unprovided inventory', () => it('does not show slice inventory for ExternalName even when supplied', () => { expect(renderToStaticMarkup()).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() + expect(html).toContain('Owner reference only') + expect(html).toContain('ownership does not establish published Service endpoints') +}) diff --git a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx index e0b648478f..9cb0b1aad7 100644 --- a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx @@ -1,6 +1,7 @@ import { type ReactNode } from 'react' import { Globe, Clock, Radio } from 'lucide-react' import { Section, PropertyList, Property, KeyValueBadgeList, CopyHandler, AlertBanner } from '../../ui/drawer-components' +import { endpointSliceServiceAssociation } from '../../../utils/endpoint-slices' import type { ResourceRef } from '../../../types' export interface ServicePortRenderProps { @@ -126,6 +127,7 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint const endpoints = slice.endpoints || [] const ready = endpointSliceReadyCount(slice) const addresses = endpointSliceAddressCount(slice) + const ownerOnly = endpointSliceServiceAssociation(slice)?.source === 'ownerReference' return (
{ready}/{endpoints.length} ready From 20810dfcd91b4cd423a53ad87275ea153c2bcea5 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 07:45:03 +0300 Subject: [PATCH 4/6] Remove the resolved Service lookup-error allowance --- internal/server/lookup_errors_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/server/lookup_errors_test.go b/internal/server/lookup_errors_test.go index 0addedc326..330b1bca30 100644 --- a/internal/server/lookup_errors_test.go +++ b/internal/server/lookup_errors_test.go @@ -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", } From 5f6ed6794edc1c6fab230863ab60351f17dd7f41 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 08:13:26 +0300 Subject: [PATCH 5/6] Keep EndpointSlice ownership verification inside the shared UI --- .../renderers/EndpointSliceOwnership.test.tsx | 12 ++++++++++++ .../resources/renderers/EndpointSliceRenderer.tsx | 5 +++-- .../resources/renderers/ServiceRenderer.test.tsx | 7 +++++++ .../resources/renderers/ServiceRenderer.tsx | 15 +++++++++++---- .../resources/renderers/ServiceRenderer.tsx | 6 +++--- 5 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 packages/k8s-ui/src/components/resources/renderers/EndpointSliceOwnership.test.tsx diff --git a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceOwnership.test.tsx b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceOwnership.test.tsx new file mode 100644 index 0000000000..54da11a7a8 --- /dev/null +++ b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceOwnership.test.tsx @@ -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({}} />) + 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({}} />) + expect(html).toContain('published');expect([...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('button')].some(button => button.textContent === 'published')).toBe(true) +}) diff --git a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx index c27df62ba4..0c67375982 100644 --- a/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/EndpointSliceRenderer.tsx @@ -43,8 +43,8 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere {serviceName && ( )} + {serviceAssociation?.source === 'ownerReference' && }
diff --git a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx index d143cc07d4..b241d28494 100644 --- a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.test.tsx @@ -27,3 +27,10 @@ it('marks unlabeled owned slices without describing them as published endpoints' 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() + expect(html).toContain('owned');expect(html).not.toContain('replaced') +}) diff --git a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx index 9cb0b1aad7..fed159ff76 100644 --- a/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx +++ b/packages/k8s-ui/src/components/resources/renderers/ServiceRenderer.tsx @@ -1,7 +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 } from '../../../utils/endpoint-slices' +import { endpointSliceServiceAssociation, endpointSliceMatchesService } from '../../../utils/endpoint-slices' import type { ResourceRef } from '../../../types' export interface ServicePortRenderProps { @@ -18,6 +18,7 @@ interface ServiceRendererProps { onCopy: CopyHandler copied: string | null endpointSlices?: any[] + endpointSliceInventory?: any[] endpointSlicesLoading?: boolean endpointSlicesEnabled?: boolean endpointSlicesError?: string @@ -41,7 +42,13 @@ function endpointSliceReadyClass(ready: number, total: number): string { return 'status-unhealthy' } -export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, endpointSlicesEnabled, endpointSlicesError, 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 || [] @@ -115,7 +122,7 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint )} {!isExternalName && (hasNoSelector || endpointSlicesEnabled) && ( -
+
{endpointSlicesLoading ? (
Loading EndpointSlices…
) : endpointSlicesError ? ( diff --git a/web/src/components/resources/renderers/ServiceRenderer.tsx b/web/src/components/resources/renderers/ServiceRenderer.tsx index 28a76e33fa..97866b41e4 100644 --- a/web/src/components/resources/renderers/ServiceRenderer.tsx +++ b/web/src/components/resources/renderers/ServiceRenderer.tsx @@ -6,7 +6,6 @@ import { useResources } from '../../../api/client' import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext' import type { ResourceRef } from '../../../types' import { DURATION_DISCLOSURE } from '@skyhook-io/k8s-ui/utils/animation' -import { endpointSliceMatchesService } from '@skyhook-io/k8s-ui/utils/endpoint-slices' interface ServiceRendererProps { data: any @@ -49,8 +48,8 @@ export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRen { enabled: shouldLoadEndpointSlices, refetchInterval: 30000 } ) const matchingEndpointSlices = useMemo( - () => (endpointSlices || []).filter((slice: any) => endpointSliceMatchesService(slice, data)), - [endpointSlices, data] + () => (endpointSlices || []).filter((slice: any) => slice.metadata?.namespace === namespace && slice.metadata?.labels?.['kubernetes.io/service-name'] === serviceName), + [endpointSlices, namespace, serviceName] ) return ( @@ -59,6 +58,7 @@ export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRen onCopy={onCopy} copied={copied} endpointSlices={matchingEndpointSlices} + endpointSliceInventory={endpointSlices} endpointSlicesLoading={endpointSlicesLoading} endpointSlicesEnabled={shouldLoadEndpointSlices} endpointSlicesError={endpointSlicesError?.message} From 0b356495ac08907cdba91c37843fc005a901fa37 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Wed, 7 Oct 2026 08:21:40 +0300 Subject: [PATCH 6/6] Keep additive Service options compatible with the pinned SDK type --- .../resources/renderers/ServiceRenderer.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/web/src/components/resources/renderers/ServiceRenderer.tsx b/web/src/components/resources/renderers/ServiceRenderer.tsx index 97866b41e4..d33fd879d6 100644 --- a/web/src/components/resources/renderers/ServiceRenderer.tsx +++ b/web/src/components/resources/renderers/ServiceRenderer.tsx @@ -52,16 +52,23 @@ export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRen [endpointSlices, namespace, serviceName] ) + // Additive options are ignored by the actual older pinned SDK, which still + // receives its label-filtered endpointSlices. Keep the host source compatible + // with that published component type as well as its runtime imports. + const endpointSliceOptions = { + endpointSliceInventory: endpointSlices, + endpointSlicesEnabled: shouldLoadEndpointSlices, + endpointSlicesError: endpointSlicesError?.message, + } + return ( ( <>