@skyhook-io/k8s-ui 1.7.9 → 1.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.9",
3
+ "version": "1.7.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -310,6 +310,16 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
310
310
  { key: 'externalIP', label: 'External', width: 'w-40', hideOnMobile: true },
311
311
  { key: 'age', label: 'Age', width: 'w-24' },
312
312
  ],
313
+ endpointslices: [
314
+ { key: 'name', label: 'Name' },
315
+ { key: 'namespace', label: 'Namespace', width: 'w-48' },
316
+ { key: 'service', label: 'Service', width: 'w-48' },
317
+ { key: 'addressType', label: 'Address Type', width: 'w-28' },
318
+ { key: 'endpoints', label: 'Endpoints', width: 'w-32' },
319
+ { key: 'addresses', label: 'Addresses', width: 'w-24' },
320
+ { key: 'ports', label: 'Ports', width: 'w-40', hideOnMobile: true },
321
+ { key: 'age', label: 'Age', width: 'w-24' },
322
+ ],
313
323
  ingresses: [
314
324
  { key: 'name', label: 'Name', width: 'min-w-40' },
315
325
  { key: 'namespace', label: 'Namespace', width: 'w-36 shrink-0' },
@@ -4509,6 +4519,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
4509
4519
  return <ReplicaSetCell resource={resource} column={column} />
4510
4520
  case 'services':
4511
4521
  return <ServiceCell resource={resource} column={column} />
4522
+ case 'endpointslices':
4523
+ return <EndpointSliceCell resource={resource} column={column} />
4512
4524
  case 'ingresses':
4513
4525
  return <IngressCell resource={resource} column={column} />
4514
4526
  case 'configmaps':
@@ -5333,6 +5345,56 @@ function ServiceCell({ resource, column }: { resource: any; column: string }) {
5333
5345
  }
5334
5346
  }
5335
5347
 
5348
+ function getEndpointSliceReadyCount(resource: any): number {
5349
+ return (resource.endpoints || []).filter((endpoint: any) => endpoint?.conditions?.ready !== false).length
5350
+ }
5351
+
5352
+ function getEndpointSliceAddressCount(resource: any): number {
5353
+ return (resource.endpoints || []).reduce((total: number, endpoint: any) => total + (endpoint.addresses?.length || 0), 0)
5354
+ }
5355
+
5356
+ function getEndpointSlicePorts(resource: any): string {
5357
+ const ports = resource.ports || []
5358
+ if (ports.length === 0) return '-'
5359
+ return ports.map((port: any) => {
5360
+ const name = port.name || 'unnamed'
5361
+ const protocol = port.protocol || 'TCP'
5362
+ return `${name}:${port.port ?? '-'}${protocol !== 'TCP' ? `/${protocol}` : ''}`
5363
+ }).join(', ')
5364
+ }
5365
+
5366
+ function EndpointSliceCell({ resource, column }: { resource: any; column: string }) {
5367
+ switch (column) {
5368
+ case 'service': {
5369
+ const service = resource.metadata?.labels?.['kubernetes.io/service-name']
5370
+ return <span className="text-sm text-theme-text-secondary truncate">{service || '-'}</span>
5371
+ }
5372
+ case 'addressType':
5373
+ return <span className="text-sm text-theme-text-secondary">{resource.addressType || '-'}</span>
5374
+ case 'endpoints': {
5375
+ const total = resource.endpoints?.length || 0
5376
+ const ready = getEndpointSliceReadyCount(resource)
5377
+ const color = total === 0 ? SEVERITY_BADGE.neutral :
5378
+ ready === total ? SEVERITY_BADGE.success :
5379
+ ready > 0 ? SEVERITY_BADGE.warning :
5380
+ SEVERITY_BADGE.error
5381
+ return <span className={clsx('badge', color)}>{ready}/{total}</span>
5382
+ }
5383
+ case 'addresses':
5384
+ return <span className="text-sm text-theme-text-secondary">{getEndpointSliceAddressCount(resource)}</span>
5385
+ case 'ports': {
5386
+ const ports = getEndpointSlicePorts(resource)
5387
+ return (
5388
+ <Tooltip content={ports}>
5389
+ <span className="text-sm text-theme-text-secondary truncate">{ports}</span>
5390
+ </Tooltip>
5391
+ )
5392
+ }
5393
+ default:
5394
+ return <span className="text-sm text-theme-text-tertiary">-</span>
5395
+ }
5396
+ }
5397
+
5336
5398
  function IngressCell({ resource, column }: { resource: any; column: string }) {
5337
5399
  switch (column) {
5338
5400
  case 'class': {
@@ -0,0 +1,141 @@
1
+ import { Radio, Server, Waypoints } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { Section, PropertyList, Property } from '../../ui/drawer-components'
4
+ import type { ResourceRef } from '../../../types'
5
+
6
+ interface EndpointSliceRendererProps {
7
+ data: any
8
+ onNavigate?: (ref: ResourceRef) => void
9
+ }
10
+
11
+ function isEndpointReady(endpoint: any): boolean {
12
+ return endpoint?.conditions?.ready !== false
13
+ }
14
+
15
+ function endpointAddressCount(endpoints: any[]): number {
16
+ return endpoints.reduce((total, endpoint) => total + (endpoint.addresses?.length || 0), 0)
17
+ }
18
+
19
+ function endpointTargetLabel(endpoint: any): string | null {
20
+ const target = endpoint.targetRef
21
+ if (!target?.kind || !target?.name) return null
22
+ return target.namespace ? `${target.kind} ${target.namespace}/${target.name}` : `${target.kind} ${target.name}`
23
+ }
24
+
25
+ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendererProps) {
26
+ const metadata = data.metadata || {}
27
+ const labels = metadata.labels || {}
28
+ const endpoints = data.endpoints || []
29
+ const ports = data.ports || []
30
+ const serviceName = labels['kubernetes.io/service-name']
31
+ const readyCount = endpoints.filter(isEndpointReady).length
32
+ const addresses = endpointAddressCount(endpoints)
33
+
34
+ return (
35
+ <>
36
+ <Section title="EndpointSlice" icon={Radio}>
37
+ <PropertyList>
38
+ <Property label="Address Type" value={data.addressType || '-'} />
39
+ <Property label="Endpoints" value={`${readyCount}/${endpoints.length} ready`} />
40
+ <Property label="Addresses" value={addresses} />
41
+ <Property label="Ports" value={ports.length} />
42
+ {serviceName && (
43
+ <Property
44
+ label="Service"
45
+ value={onNavigate ? (
46
+ <button
47
+ type="button"
48
+ className="text-sm text-accent-text hover:underline font-medium"
49
+ onClick={() => onNavigate({ kind: 'Service', namespace: metadata.namespace, name: serviceName })}
50
+ >
51
+ {serviceName}
52
+ </button>
53
+ ) : serviceName}
54
+ />
55
+ )}
56
+ </PropertyList>
57
+ </Section>
58
+
59
+ {ports.length > 0 && (
60
+ <Section title="Ports" icon={Waypoints}>
61
+ <div className="space-y-2">
62
+ {ports.map((port: any, index: number) => (
63
+ <div key={`${port.name || 'port'}-${port.port || index}-${port.protocol || 'TCP'}`} className="card-inner text-sm">
64
+ <div className="flex items-center justify-between gap-3">
65
+ <div className="min-w-0">
66
+ <div className="font-medium text-theme-text-primary truncate">{port.name || `port-${index + 1}`}</div>
67
+ {port.appProtocol && (
68
+ <div className="text-xs text-theme-text-tertiary mt-0.5">{port.appProtocol}</div>
69
+ )}
70
+ </div>
71
+ <div className="flex items-center gap-2 shrink-0">
72
+ <span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{port.protocol || 'TCP'}</span>
73
+ <span className="font-mono text-theme-text-secondary">{port.port ?? '-'}</span>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ </Section>
80
+ )}
81
+
82
+ {endpoints.length > 0 && (
83
+ <Section title="Endpoints" icon={Server}>
84
+ <div className="space-y-2">
85
+ {endpoints.map((endpoint: any, index: number) => {
86
+ const ready = isEndpointReady(endpoint)
87
+ const targetLabel = endpointTargetLabel(endpoint)
88
+ const target = endpoint.targetRef
89
+ return (
90
+ <div key={`${endpoint.addresses?.join(',') || 'endpoint'}-${index}`} className="card-inner text-sm space-y-3">
91
+ <div className="flex items-start justify-between gap-3">
92
+ <div className="min-w-0 space-y-1">
93
+ <div className="flex flex-wrap items-center gap-1.5">
94
+ {(endpoint.addresses || []).map((address: string) => (
95
+ <span key={address} className="badge-sm bg-theme-elevated text-theme-text-primary border border-theme-border font-mono">
96
+ {address}
97
+ </span>
98
+ ))}
99
+ </div>
100
+ {targetLabel && (
101
+ <button
102
+ type="button"
103
+ className="text-xs text-theme-text-secondary hover:text-accent-text"
104
+ disabled={!onNavigate}
105
+ onClick={() => onNavigate?.({
106
+ kind: target.kind,
107
+ namespace: target.namespace || metadata.namespace,
108
+ name: target.name,
109
+ })}
110
+ >
111
+ {targetLabel}
112
+ </button>
113
+ )}
114
+ </div>
115
+ <div className="flex flex-wrap justify-end gap-1.5 shrink-0">
116
+ <span className={clsx('badge-sm', ready ? 'status-healthy' : 'status-unhealthy')}>
117
+ {ready ? 'Ready' : 'Not Ready'}
118
+ </span>
119
+ {endpoint.conditions?.serving === false && (
120
+ <span className="badge-sm status-degraded">Not Serving</span>
121
+ )}
122
+ {endpoint.conditions?.terminating === true && (
123
+ <span className="badge-sm status-alert">Terminating</span>
124
+ )}
125
+ </div>
126
+ </div>
127
+ {(endpoint.nodeName || endpoint.zone) && (
128
+ <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-theme-text-tertiary">
129
+ {endpoint.nodeName && <span>Node: {endpoint.nodeName}</span>}
130
+ {endpoint.zone && <span>Zone: {endpoint.zone}</span>}
131
+ </div>
132
+ )}
133
+ </div>
134
+ )
135
+ })}
136
+ </div>
137
+ </Section>
138
+ )}
139
+ </>
140
+ )
141
+ }
@@ -1,15 +1,34 @@
1
1
  import { type ReactNode } from 'react'
2
- import { Globe, Clock } from 'lucide-react'
2
+ import { Globe, Clock, Radio } from 'lucide-react'
3
3
  import { Section, PropertyList, Property, KeyValueBadgeList, CopyHandler, AlertBanner } from '../../ui/drawer-components'
4
+ import type { ResourceRef } from '../../../types'
4
5
 
5
6
  interface ServiceRendererProps {
6
7
  data: any
7
8
  onCopy: CopyHandler
8
9
  copied: string | null
10
+ endpointSlices?: any[]
11
+ endpointSlicesLoading?: boolean
12
+ onNavigate?: (ref: ResourceRef) => void
9
13
  renderPortAction?: (props: { namespace: string; serviceName: string; port: number; protocol: string }) => ReactNode
10
14
  }
11
15
 
12
- export function ServiceRenderer({ data, onCopy, copied, renderPortAction }: ServiceRendererProps) {
16
+ function endpointSliceAddressCount(slice: any): number {
17
+ return (slice.endpoints || []).reduce((total: number, endpoint: any) => total + (endpoint.addresses?.length || 0), 0)
18
+ }
19
+
20
+ function endpointSliceReadyCount(slice: any): number {
21
+ return (slice.endpoints || []).filter((endpoint: any) => endpoint?.conditions?.ready !== false).length
22
+ }
23
+
24
+ function endpointSliceReadyClass(ready: number, total: number): string {
25
+ if (total === 0) return 'status-unknown'
26
+ if (ready === total) return 'status-healthy'
27
+ if (ready > 0) return 'status-degraded'
28
+ return 'status-unhealthy'
29
+ }
30
+
31
+ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, onNavigate, renderPortAction }: ServiceRendererProps) {
13
32
  const spec = data.spec || {}
14
33
  const ports = spec.ports || []
15
34
  const lbIngress = data.status?.loadBalancer?.ingress || []
@@ -105,6 +124,49 @@ export function ServiceRenderer({ data, onCopy, copied, renderPortAction }: Serv
105
124
  <KeyValueBadgeList items={spec.selector} />
106
125
  </Section>
107
126
  )}
127
+
128
+ {hasNoSelector && !isExternalName && (
129
+ <Section title="EndpointSlices" icon={Radio}>
130
+ {endpointSlicesLoading ? (
131
+ <div className="text-sm text-theme-text-tertiary">Loading EndpointSlices...</div>
132
+ ) : endpointSlices && endpointSlices.length > 0 ? (
133
+ <div className="space-y-2">
134
+ {endpointSlices.map((slice: any) => {
135
+ const sliceName = slice.metadata?.name
136
+ const endpoints = slice.endpoints || []
137
+ const ready = endpointSliceReadyCount(slice)
138
+ const addresses = endpointSliceAddressCount(slice)
139
+ return (
140
+ <button
141
+ key={slice.metadata?.uid || sliceName}
142
+ type="button"
143
+ className="card-inner w-full text-left hover:bg-theme-hover transition-colors"
144
+ onClick={() => onNavigate?.({
145
+ kind: 'EndpointSlice',
146
+ group: 'discovery.k8s.io',
147
+ namespace,
148
+ name: sliceName,
149
+ })}
150
+ >
151
+ <div className="flex items-center justify-between gap-3">
152
+ <div className="min-w-0">
153
+ <div className="text-sm font-medium text-theme-text-primary truncate">{sliceName}</div>
154
+ <div className="text-xs text-theme-text-tertiary mt-0.5">{slice.addressType || 'Unknown'} address type</div>
155
+ </div>
156
+ <div className="flex flex-wrap items-center justify-end gap-1.5 shrink-0">
157
+ <span className={`badge-sm ${endpointSliceReadyClass(ready, endpoints.length)}`}>{ready}/{endpoints.length} ready</span>
158
+ <span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{addresses} addresses</span>
159
+ </div>
160
+ </div>
161
+ </button>
162
+ )
163
+ })}
164
+ </div>
165
+ ) : (
166
+ <div className="text-sm text-theme-text-tertiary">No EndpointSlices found for this Service.</div>
167
+ )}
168
+ </Section>
169
+ )}
108
170
  </>
109
171
  )
110
172
  }
@@ -25,6 +25,7 @@ export * from './ConfigMapRenderer'
25
25
  export * from './CronJobRenderer'
26
26
  export * from './eso-cells'
27
27
  export * from './EventRenderer'
28
+ export * from './EndpointSliceRenderer'
28
29
  export * from './ExposedSecretReportRenderer'
29
30
  export * from './ExternalSecretRenderer'
30
31
  export * from './flux-cells'
@@ -110,6 +110,7 @@ import {
110
110
  RoleBindingRenderer,
111
111
  WebhookConfigRenderer,
112
112
  EventRenderer,
113
+ EndpointSliceRenderer,
113
114
  GenericRenderer,
114
115
  GitRepositoryRenderer,
115
116
  OCIRepositoryRenderer,
@@ -242,6 +243,7 @@ export interface RendererOverrides {
242
243
  }>
243
244
  ServiceRenderer?: React.ComponentType<{
244
245
  data: any; onCopy: CopyHandler; copied: string | null
246
+ onNavigate?: (ref: ResourceRef) => void
245
247
  }>
246
248
  WorkloadRenderer?: React.ComponentType<{
247
249
  kind: string; data: any
@@ -299,7 +301,7 @@ export interface RendererOverrides {
299
301
  // Known resource types with specific renderers (module-level to avoid re-allocation)
300
302
  const KNOWN_KINDS = new Set([
301
303
  'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
302
- 'services', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs',
304
+ 'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs',
303
305
  'hpas', 'horizontalpodautoscalers', 'nodes', 'persistentvolumeclaims',
304
306
  'rollouts', 'certificates', 'workflows', 'persistentvolumes',
305
307
  'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers',
@@ -494,7 +496,8 @@ export function ResourceRendererDispatch({
494
496
  />
495
497
  )}
496
498
  {kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
497
- {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} />}
499
+ {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
500
+ {kind === 'endpointslices' && <EndpointSliceRenderer data={data} onNavigate={onNavigate} />}
498
501
  {kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
499
502
  {kind === 'configmaps' && <ConfigMapRenderer data={data} />}
500
503
  {kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
@@ -697,6 +700,16 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
697
700
  }
698
701
  return getServiceStatus(data)
699
702
  }
703
+ if (k === 'endpointslices') {
704
+ const endpoints = data.endpoints || []
705
+ const ready = endpoints.filter((endpoint: any) => endpoint?.conditions?.ready !== false).length
706
+ const text = endpoints.length === 0 ? 'No endpoints' : `${ready}/${endpoints.length} ready`
707
+ const color = endpoints.length === 0 ? SEVERITY_BADGE.neutral :
708
+ ready === endpoints.length ? SEVERITY_BADGE.success :
709
+ ready > 0 ? SEVERITY_BADGE.warning :
710
+ SEVERITY_BADGE.error
711
+ return { text, color }
712
+ }
700
713
  if (k === 'jobs') return getJobStatus(data)
701
714
  if (k === 'cronjobs') return getCronJobStatus(data)
702
715
  if (k === 'hpas' || k === 'horizontalpodautoscalers') return getHPAStatus(data)
@@ -54,6 +54,8 @@ const KIND: Record<string, string> = {
54
54
 
55
55
  // Networking
56
56
  Service: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
57
+ Endpoints: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
58
+ EndpointSlice: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
57
59
  Internet: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
58
60
  Ingress: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
59
61
  Gateway: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
@@ -64,8 +64,21 @@ export function MiddleEllipsis({ text, className, title, onTruncatedChange }: Mi
64
64
  // ctx.measureText says 173.4px and clientWidth rounds to 173 — the
65
65
  // integer comparison decides we don't fit and middle-truncates a
66
66
  // name that visually would have rendered fine.
67
- const width = node.getBoundingClientRect().width
68
- if (width <= 0) return
67
+ const rawWidth = node.getBoundingClientRect().width
68
+ if (rawWidth <= 0) return
69
+ // getBoundingClientRect() returns the *rendered* width, which a CSS
70
+ // transform on this node or any ancestor scales — e.g. the `scale()` in a
71
+ // menu/popup/dialog entry animation. Measuring during that animation
72
+ // compares a visually-shrunk container against unscaled canvas text
73
+ // (measureText is transform-independent) and middle-truncates a name that
74
+ // actually fits. Worse, a transform never changes the layout box, so the
75
+ // ResizeObserver below doesn't fire to re-measure once the animation
76
+ // settles — the wrong truncation then sticks. Divide out the cumulative
77
+ // ancestor scaleX to recover the unscaled (still subpixel) layout width.
78
+ // With no transform the scale is 1 and this is a no-op, so the just-fits
79
+ // subpixel precision noted above is preserved.
80
+ const scaleX = cumulativeScaleX(node)
81
+ const width = scaleX > 0 ? rawWidth / scaleX : rawWidth
69
82
  const cs = window.getComputedStyle(node)
70
83
  // Include fontStyle in the shorthand so italic faces measure correctly.
71
84
  // If the assignment ever silently fails (malformed family quoting,
@@ -128,6 +141,29 @@ export function MiddleEllipsis({ text, className, title, onTruncatedChange }: Mi
128
141
  )
129
142
  }
130
143
 
144
+ // Cumulative horizontal scale applied to `el` by CSS transforms on it and its
145
+ // ancestors. Used to convert a transform-scaled getBoundingClientRect() width
146
+ // back to the unscaled layout width, so a `scale()` entry animation on an
147
+ // ancestor (menu/popup/dialog) can't make text middle-truncate when it would
148
+ // otherwise fit. Reads the computed transform matrix's scaleX (`a`) at each
149
+ // level — exact for the scale()/translate() animations this guards against
150
+ // (no rotation, where `a` would fold in cos θ). Returns 1 when nothing in the
151
+ // chain is transformed, making the caller a no-op in the common case.
152
+ function cumulativeScaleX(el: HTMLElement | null): number {
153
+ let scale = 1
154
+ for (let cur: HTMLElement | null = el; cur; cur = cur.parentElement) {
155
+ const t = window.getComputedStyle(cur).transform
156
+ if (t && t !== 'none') {
157
+ try {
158
+ scale *= new DOMMatrixReadOnly(t).a
159
+ } catch {
160
+ // Unparseable transform value — skip this level rather than throw.
161
+ }
162
+ }
163
+ }
164
+ return scale
165
+ }
166
+
131
167
  // Binary-search the largest `n` such that `prefix(n) + … + suffix(n)` fits
132
168
  // in `width`. Symmetric on purpose — keeping prefix and suffix balanced is
133
169
  // the cheapest way to preserve the most identifying chars on both ends of
@@ -657,7 +657,7 @@ export function formatKindName(kind: string): string {
657
657
  const k = kind.toLowerCase()
658
658
  const names: Record<string, string> = {
659
659
  pods: 'Pod', deployments: 'Deployment', daemonsets: 'DaemonSet', statefulsets: 'StatefulSet',
660
- replicasets: 'ReplicaSet', services: 'Service', ingresses: 'Ingress',
660
+ replicasets: 'ReplicaSet', services: 'Service', endpointslices: 'EndpointSlice', ingresses: 'Ingress',
661
661
  gateways: 'Gateway', httproutes: 'HTTPRoute', grpcroutes: 'GRPCRoute',
662
662
  tcproutes: 'TCPRoute', tlsroutes: 'TLSRoute', configmaps: 'ConfigMap',
663
663
  secrets: 'Secret', jobs: 'Job', cronjobs: 'CronJob', hpas: 'HPA',
@@ -33,6 +33,7 @@ export const CORE_RESOURCES: APIResource[] = [
33
33
  { group: 'batch', version: 'v1', kind: 'CronJob', name: 'cronjobs', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
34
34
  { group: 'networking.k8s.io', version: 'v1', kind: 'Ingress', name: 'ingresses', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
35
35
  { group: 'networking.k8s.io', version: 'v1', kind: 'NetworkPolicy', name: 'networkpolicies', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
36
+ { group: 'discovery.k8s.io', version: 'v1', kind: 'EndpointSlice', name: 'endpointslices', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
36
37
  { group: 'autoscaling', version: 'v2', kind: 'HorizontalPodAutoscaler', name: 'horizontalpodautoscalers', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
37
38
  { group: '', version: 'v1', kind: 'Event', name: 'events', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
38
39
  { group: 'rbac.authorization.k8s.io', version: 'v1', kind: 'Role', name: 'roles', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
@@ -32,6 +32,11 @@ describe('kindToPlural', () => {
32
32
  expect(pluralToKind('endpoints')).toBe('Endpoints')
33
33
  })
34
34
 
35
+ test('handles EndpointSlice before discovery loads', () => {
36
+ expect(kindToPlural('EndpointSlice')).toBe('endpointslices')
37
+ expect(pluralToKind('endpointslices')).toBe('EndpointSlice')
38
+ })
39
+
35
40
  test('handles kinds ending in ss (Class-suffix)', () => {
36
41
  expect(kindToPlural('StorageClass')).toBe('storageclasses')
37
42
  expect(kindToPlural('IngressClass')).toBe('ingressclasses')
@@ -12,6 +12,7 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
12
12
  pods: 'Pod',
13
13
  services: 'Service',
14
14
  endpoints: 'Endpoints', // already-plural resource name; englishPlural would yield "endpointses"
15
+ endpointslices: 'EndpointSlice',
15
16
  deployments: 'Deployment',
16
17
  daemonsets: 'DaemonSet',
17
18
  statefulsets: 'StatefulSet',
@@ -48,6 +48,25 @@ spec:
48
48
  targetPort: 80
49
49
  type: ClusterIP`,
50
50
 
51
+ EndpointSlice: `apiVersion: discovery.k8s.io/v1
52
+ kind: EndpointSlice
53
+ metadata:
54
+ name: my-service-1
55
+ namespace: default
56
+ labels:
57
+ kubernetes.io/service-name: my-service
58
+ addressType: IPv4
59
+ ports:
60
+ - name: http
61
+ protocol: TCP
62
+ port: 80
63
+ endpoints:
64
+ - addresses:
65
+ - 10.0.0.10
66
+ conditions:
67
+ ready: true
68
+ serving: true`,
69
+
51
70
  ConfigMap: `apiVersion: v1
52
71
  kind: ConfigMap
53
72
  metadata: