@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 +1 -1
- package/src/components/resources/ResourcesView.tsx +62 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +141 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +64 -2
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -2
- package/src/components/ui/Badge.tsx +2 -0
- package/src/components/ui/MiddleEllipsis.tsx +38 -2
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +1 -0
- package/src/utils/skeleton-yaml.ts +19 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
68
|
-
if (
|
|
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')
|
package/src/utils/navigation.ts
CHANGED
|
@@ -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:
|