@skyhook-io/k8s-ui 1.0.0 → 1.1.1
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 +4 -1
- package/src/components/resources/ResourcesView.tsx +50 -43
- package/src/components/resources/renderers/HPARenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +2 -1
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -1
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +7 -6
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +2 -1
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +3 -2
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/VPARenderer.tsx +2 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +53 -55
- package/src/components/shared/ResourceActionsBar.tsx +141 -142
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -4
- package/src/components/shared/index.ts +1 -1
- package/src/components/topology/TopologyGraph.tsx +282 -19
- package/src/components/ui/ConfirmDialog.tsx +85 -130
- package/src/components/ui/DialogPortal.tsx +82 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +21 -1
- package/src/types/core.ts +1 -2
- package/src/utils/navigation.test.ts +142 -0
- package/src/utils/navigation.ts +52 -47
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
5
|
+
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
6
|
+
|
|
7
|
+
interface DialogPortalProps {
|
|
8
|
+
open: boolean
|
|
9
|
+
onClose: () => void
|
|
10
|
+
children: ReactNode
|
|
11
|
+
/** Extra classes on the panel container (width, max-height, etc.) */
|
|
12
|
+
className?: string
|
|
13
|
+
/** Prevent closing via Escape / backdrop click (e.g. during async operation) */
|
|
14
|
+
closable?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Minimal dialog primitive — handles portal, backdrop, escape, focus, animation.
|
|
19
|
+
* Renders children inside a centered panel portaled to document.body, so it works
|
|
20
|
+
* correctly even inside CSS-transformed containers (drawers, slide panels).
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* <DialogPortal open={showDialog} onClose={() => setShowDialog(false)} className="w-80">
|
|
24
|
+
* <h3>Title</h3>
|
|
25
|
+
* <p>Content</p>
|
|
26
|
+
* </DialogPortal>
|
|
27
|
+
*/
|
|
28
|
+
export function DialogPortal({ open, onClose, children, className, closable = true }: DialogPortalProps) {
|
|
29
|
+
const dialogRef = useRef<HTMLDivElement>(null)
|
|
30
|
+
const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
|
|
31
|
+
|
|
32
|
+
// Capture-phase ESC handler — stops event before it reaches document listeners (e.g. drawer shortcuts)
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return
|
|
35
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
36
|
+
if (e.key === 'Escape' && closable) {
|
|
37
|
+
e.stopPropagation()
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
onClose()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
43
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
44
|
+
}, [open, onClose, closable])
|
|
45
|
+
|
|
46
|
+
// Move focus into the dialog for accessibility and tab navigation
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (open && dialogRef.current) {
|
|
49
|
+
dialogRef.current.focus()
|
|
50
|
+
}
|
|
51
|
+
}, [open])
|
|
52
|
+
|
|
53
|
+
if (!shouldRender) return null
|
|
54
|
+
|
|
55
|
+
return createPortal(
|
|
56
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
57
|
+
<div
|
|
58
|
+
className={clsx(
|
|
59
|
+
'absolute inset-0 bg-black/60 backdrop-blur-sm',
|
|
60
|
+
TRANSITION_BACKDROP,
|
|
61
|
+
isOpen ? 'opacity-100' : 'opacity-0',
|
|
62
|
+
)}
|
|
63
|
+
onClick={closable ? onClose : undefined}
|
|
64
|
+
/>
|
|
65
|
+
<div
|
|
66
|
+
ref={dialogRef}
|
|
67
|
+
role="dialog"
|
|
68
|
+
aria-modal="true"
|
|
69
|
+
tabIndex={-1}
|
|
70
|
+
className={clsx(
|
|
71
|
+
'relative bg-theme-surface border border-theme-border rounded-lg shadow-2xl mx-4 outline-none',
|
|
72
|
+
TRANSITION_PANEL,
|
|
73
|
+
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
|
|
74
|
+
className,
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
</div>
|
|
79
|
+
</div>,
|
|
80
|
+
document.body,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
} from '../timeline/shared'
|
|
39
39
|
import { ResourceActionsBar } from '../shared/ResourceActionsBar'
|
|
40
40
|
import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
|
|
41
|
-
import { ResourceRendererDispatch, getResourceStatus } from '../shared/ResourceRendererDispatch'
|
|
41
|
+
import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from '../shared/ResourceRendererDispatch'
|
|
42
42
|
import { getKindColor, formatKindName } from '../ui/drawer-components'
|
|
43
43
|
|
|
44
44
|
type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
|
|
@@ -94,6 +94,8 @@ interface WorkloadViewProps {
|
|
|
94
94
|
onUpdateResource?: (params: { kind: string; namespace: string; name: string; yaml: string }) => Promise<void>
|
|
95
95
|
/** Whether the resource is being updated */
|
|
96
96
|
isUpdatingResource?: boolean
|
|
97
|
+
/** Error message from the last update attempt */
|
|
98
|
+
updateResourceError?: string | null
|
|
97
99
|
|
|
98
100
|
// ── Tab state (optional URL sync) ────────────────────────────────────────
|
|
99
101
|
/** Controlled active tab. If not provided, managed internally. */
|
|
@@ -123,6 +125,10 @@ interface WorkloadViewProps {
|
|
|
123
125
|
// ── ResourceActionsBar props (passed through) ────────────────────────────
|
|
124
126
|
/** All props for the actions bar (forwarded as-is) */
|
|
125
127
|
actionsBarProps?: Record<string, any>
|
|
128
|
+
|
|
129
|
+
// ── Renderer overrides ──────────────────────────────────────────────────
|
|
130
|
+
/** Platform-specific renderer overrides (e.g. with hooks for metrics, exec, port-forward) */
|
|
131
|
+
rendererOverrides?: RendererOverrides
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
export function WorkloadView({
|
|
@@ -152,6 +158,7 @@ export function WorkloadView({
|
|
|
152
158
|
// Mutations
|
|
153
159
|
onUpdateResource,
|
|
154
160
|
isUpdatingResource,
|
|
161
|
+
updateResourceError,
|
|
155
162
|
// Tab state
|
|
156
163
|
activeTab: controlledTab,
|
|
157
164
|
onTabChange,
|
|
@@ -161,6 +168,8 @@ export function WorkloadView({
|
|
|
161
168
|
isMetricsAvailable,
|
|
162
169
|
// Actions bar
|
|
163
170
|
actionsBarProps,
|
|
171
|
+
// Renderer overrides
|
|
172
|
+
rendererOverrides,
|
|
164
173
|
}: WorkloadViewProps) {
|
|
165
174
|
// Normalize kind: URL has plural lowercase, internal logic uses singular PascalCase
|
|
166
175
|
const kind = pluralToKind(kindProp)
|
|
@@ -416,6 +425,9 @@ export function WorkloadView({
|
|
|
416
425
|
onCopy={(text) => copyToClipboard(text, 'yaml')}
|
|
417
426
|
copied={copied === 'yaml'}
|
|
418
427
|
onSaved={handleSaved}
|
|
428
|
+
onSave={onUpdateResource}
|
|
429
|
+
isSaving={isUpdatingResource}
|
|
430
|
+
saveError={updateResourceError}
|
|
419
431
|
/>
|
|
420
432
|
) : (
|
|
421
433
|
<ResourceRendererDispatch
|
|
@@ -428,6 +440,7 @@ export function WorkloadView({
|
|
|
428
440
|
onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
|
|
429
441
|
onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
|
|
430
442
|
isSavingSecret={isUpdatingResource}
|
|
443
|
+
rendererOverrides={rendererOverrides}
|
|
431
444
|
/>
|
|
432
445
|
)}
|
|
433
446
|
</div>
|
|
@@ -566,6 +579,7 @@ export function WorkloadView({
|
|
|
566
579
|
isSavingSecret={isUpdatingResource}
|
|
567
580
|
onOpenLogs={handleOpenLogs}
|
|
568
581
|
onSwitchToTimeline={() => handleSetTab('timeline')}
|
|
582
|
+
rendererOverrides={rendererOverrides}
|
|
569
583
|
/>
|
|
570
584
|
)}
|
|
571
585
|
{activeTab === 'timeline' && (
|
|
@@ -613,6 +627,9 @@ export function WorkloadView({
|
|
|
613
627
|
onCopy={(text) => copyToClipboard(text, 'yaml')}
|
|
614
628
|
copied={copied === 'yaml'}
|
|
615
629
|
onSaved={handleSaved}
|
|
630
|
+
onSave={onUpdateResource}
|
|
631
|
+
isSaving={isUpdatingResource}
|
|
632
|
+
saveError={updateResourceError}
|
|
616
633
|
/>
|
|
617
634
|
)}
|
|
618
635
|
</div>
|
|
@@ -1044,6 +1061,7 @@ function InfoTab({
|
|
|
1044
1061
|
isSavingSecret,
|
|
1045
1062
|
onOpenLogs,
|
|
1046
1063
|
onSwitchToTimeline,
|
|
1064
|
+
rendererOverrides,
|
|
1047
1065
|
}: {
|
|
1048
1066
|
resource: any
|
|
1049
1067
|
selectedResource: SelectedResource
|
|
@@ -1056,6 +1074,7 @@ function InfoTab({
|
|
|
1056
1074
|
isSavingSecret?: boolean
|
|
1057
1075
|
onOpenLogs?: (podName: string, containerName: string) => void
|
|
1058
1076
|
onSwitchToTimeline?: () => void
|
|
1077
|
+
rendererOverrides?: RendererOverrides
|
|
1059
1078
|
}) {
|
|
1060
1079
|
if (isLoading) {
|
|
1061
1080
|
return (
|
|
@@ -1088,6 +1107,7 @@ function InfoTab({
|
|
|
1088
1107
|
showCommonSections={true}
|
|
1089
1108
|
showMetrics={false}
|
|
1090
1109
|
onOpenLogs={onOpenLogs}
|
|
1110
|
+
rendererOverrides={rendererOverrides}
|
|
1091
1111
|
eventsHint={onSwitchToTimeline && (
|
|
1092
1112
|
<button
|
|
1093
1113
|
onClick={onSwitchToTimeline}
|
package/src/types/core.ts
CHANGED
|
@@ -146,10 +146,9 @@ export interface Topology {
|
|
|
146
146
|
nodes: TopologyNode[]
|
|
147
147
|
edges: TopologyEdge[]
|
|
148
148
|
warnings?: string[] // Warnings about resources that failed to load
|
|
149
|
-
truncated?: boolean // True if topology was truncated due to size limit
|
|
150
|
-
totalNodes?: number // Total nodes before truncation (only set if truncated)
|
|
151
149
|
largeCluster?: boolean // True if cluster exceeds large cluster threshold
|
|
152
150
|
hiddenKinds?: string[] // Resource kinds auto-hidden for performance
|
|
151
|
+
requiresNamespaceFilter?: boolean // True if cluster is too large for all-namespace topology
|
|
153
152
|
crdDiscoveryStatus?: 'idle' | 'discovering' | 'ready' // CRD discovery status
|
|
154
153
|
}
|
|
155
154
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
import { kindToPlural, pluralToKind, refToSelectedResource } from './navigation'
|
|
3
|
+
|
|
4
|
+
describe('kindToPlural', () => {
|
|
5
|
+
test('singular PascalCase to plural lowercase', () => {
|
|
6
|
+
expect(kindToPlural('Secret')).toBe('secrets')
|
|
7
|
+
expect(kindToPlural('Deployment')).toBe('deployments')
|
|
8
|
+
expect(kindToPlural('Pod')).toBe('pods')
|
|
9
|
+
expect(kindToPlural('Service')).toBe('services')
|
|
10
|
+
expect(kindToPlural('ConfigMap')).toBe('configmaps')
|
|
11
|
+
expect(kindToPlural('Node')).toBe('nodes')
|
|
12
|
+
expect(kindToPlural('Job')).toBe('jobs')
|
|
13
|
+
expect(kindToPlural('CronJob')).toBe('cronjobs')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('handles kinds ending in s/x/ch/sh (adds -es)', () => {
|
|
17
|
+
expect(kindToPlural('Ingress')).toBe('ingresses')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('handles kinds ending in consonant+y (changes to -ies)', () => {
|
|
21
|
+
expect(kindToPlural('NetworkPolicy')).toBe('networkpolicies')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('handles kinds ending in ss (Class-suffix)', () => {
|
|
25
|
+
expect(kindToPlural('StorageClass')).toBe('storageclasses')
|
|
26
|
+
expect(kindToPlural('IngressClass')).toBe('ingressclasses')
|
|
27
|
+
expect(kindToPlural('PriorityClass')).toBe('priorityclasses')
|
|
28
|
+
expect(kindToPlural('RuntimeClass')).toBe('runtimeclasses')
|
|
29
|
+
expect(kindToPlural('GatewayClass')).toBe('gatewayclasses')
|
|
30
|
+
expect(kindToPlural('EC2NodeClass')).toBe('ec2nodeclasses')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('idempotent on known plurals (prevents double-pluralization)', () => {
|
|
34
|
+
// This was the original bug: "secrets" → "secretses"
|
|
35
|
+
expect(kindToPlural('secrets')).toBe('secrets')
|
|
36
|
+
expect(kindToPlural('services')).toBe('services')
|
|
37
|
+
expect(kindToPlural('ingresses')).toBe('ingresses')
|
|
38
|
+
expect(kindToPlural('deployments')).toBe('deployments')
|
|
39
|
+
expect(kindToPlural('pods')).toBe('pods')
|
|
40
|
+
expect(kindToPlural('configmaps')).toBe('configmaps')
|
|
41
|
+
expect(kindToPlural('nodes')).toBe('nodes')
|
|
42
|
+
expect(kindToPlural('storageclasses')).toBe('storageclasses')
|
|
43
|
+
expect(kindToPlural('networkpolicies')).toBe('networkpolicies')
|
|
44
|
+
expect(kindToPlural('horizontalpodautoscalers')).toBe('horizontalpodautoscalers')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('handles aliases', () => {
|
|
48
|
+
expect(kindToPlural('HorizontalPodAutoscaler')).toBe('horizontalpodautoscalers')
|
|
49
|
+
expect(kindToPlural('pvc')).toBe('persistentvolumeclaims')
|
|
50
|
+
expect(kindToPlural('PodGroup')).toBe('pods')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Demonstrate that the naive .toLowerCase() + 's' pattern used by renderers is broken.
|
|
55
|
+
// These tests prove WHY renderers must use kindToPlural() instead of ad-hoc pluralization.
|
|
56
|
+
describe('naive pluralization (renderer bug demonstration)', () => {
|
|
57
|
+
const naivePlural = (kind: string) => kind.toLowerCase() + 's'
|
|
58
|
+
|
|
59
|
+
test('breaks for Class-suffix kinds (triple-s)', () => {
|
|
60
|
+
// What HPARenderer, KarpenterNodePoolRenderer, etc. actually produce
|
|
61
|
+
expect(naivePlural('EC2NodeClass')).toBe('ec2nodeclasss') // WRONG
|
|
62
|
+
expect(kindToPlural('EC2NodeClass')).toBe('ec2nodeclasses') // CORRECT
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('breaks for Policy-suffix kinds', () => {
|
|
66
|
+
expect(naivePlural('NetworkPolicy')).toBe('networkpolicys') // WRONG
|
|
67
|
+
expect(kindToPlural('NetworkPolicy')).toBe('networkpolicies') // CORRECT
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('breaks for Ingress-like kinds (ending in s)', () => {
|
|
71
|
+
expect(naivePlural('Ingress')).toBe('ingresss') // WRONG
|
|
72
|
+
expect(kindToPlural('Ingress')).toBe('ingresses') // CORRECT
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('breaks for Repository-suffix kinds', () => {
|
|
76
|
+
expect(naivePlural('GitRepository')).toBe('gitrepositorys') // WRONG
|
|
77
|
+
expect(kindToPlural('GitRepository')).toBe('gitrepositories') // CORRECT
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('pluralToKind', () => {
|
|
82
|
+
test('reverse mapping for known plurals', () => {
|
|
83
|
+
expect(pluralToKind('secrets')).toBe('Secret')
|
|
84
|
+
expect(pluralToKind('deployments')).toBe('Deployment')
|
|
85
|
+
expect(pluralToKind('horizontalpodautoscalers')).toBe('HorizontalPodAutoscaler')
|
|
86
|
+
expect(pluralToKind('ingresses')).toBe('Ingress')
|
|
87
|
+
expect(pluralToKind('configmaps')).toBe('ConfigMap')
|
|
88
|
+
expect(pluralToKind('networkpolicies')).toBe('NetworkPolicy')
|
|
89
|
+
expect(pluralToKind('storageclasses')).toBe('StorageClass')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('PascalCase input returned as-is', () => {
|
|
93
|
+
expect(pluralToKind('Deployment')).toBe('Deployment')
|
|
94
|
+
expect(pluralToKind('Secret')).toBe('Secret')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('fallback de-pluralization for unknown kinds', () => {
|
|
98
|
+
expect(pluralToKind('widgets')).toBe('Widget')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('fallback handles -ies suffix', () => {
|
|
102
|
+
// Unknown kind not in the map
|
|
103
|
+
expect(pluralToKind('batteries')).toBe('Battery')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('fallback handles -ses suffix', () => {
|
|
107
|
+
// "databases" triggers the -ses rule (strips 2 chars) — a known limitation
|
|
108
|
+
// of the heuristic fallback. Known kinds use the PLURAL_TO_KIND map instead.
|
|
109
|
+
expect(pluralToKind('databases')).toBe('Databas')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('refToSelectedResource', () => {
|
|
114
|
+
test('converts singular kind to plural for navigation', () => {
|
|
115
|
+
const result = refToSelectedResource({
|
|
116
|
+
kind: 'Secret',
|
|
117
|
+
name: 'test-tls',
|
|
118
|
+
namespace: 'platform',
|
|
119
|
+
})
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
kind: 'secrets',
|
|
122
|
+
name: 'test-tls',
|
|
123
|
+
namespace: 'platform',
|
|
124
|
+
group: undefined,
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('preserves group field', () => {
|
|
129
|
+
const result = refToSelectedResource({
|
|
130
|
+
kind: 'Certificate',
|
|
131
|
+
name: 'my-cert',
|
|
132
|
+
namespace: 'default',
|
|
133
|
+
group: 'cert-manager.io',
|
|
134
|
+
})
|
|
135
|
+
expect(result).toEqual({
|
|
136
|
+
kind: 'certificates',
|
|
137
|
+
name: 'my-cert',
|
|
138
|
+
namespace: 'default',
|
|
139
|
+
group: 'cert-manager.io',
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
package/src/utils/navigation.ts
CHANGED
|
@@ -6,14 +6,65 @@ import type { SelectedResource, ResourceRef } from '../types/core'
|
|
|
6
6
|
*/
|
|
7
7
|
export type NavigateToResource = (resource: SelectedResource) => void
|
|
8
8
|
|
|
9
|
+
// Known plural API resource names → singular PascalCase kind.
|
|
10
|
+
// Shared between kindToPlural (idempotency guard) and pluralToKind (reverse lookup).
|
|
11
|
+
const PLURAL_TO_KIND: Record<string, string> = {
|
|
12
|
+
pods: 'Pod',
|
|
13
|
+
services: 'Service',
|
|
14
|
+
deployments: 'Deployment',
|
|
15
|
+
daemonsets: 'DaemonSet',
|
|
16
|
+
statefulsets: 'StatefulSet',
|
|
17
|
+
replicasets: 'ReplicaSet',
|
|
18
|
+
ingresses: 'Ingress',
|
|
19
|
+
gateways: 'Gateway',
|
|
20
|
+
httproutes: 'HTTPRoute',
|
|
21
|
+
grpcroutes: 'GRPCRoute',
|
|
22
|
+
tcproutes: 'TCPRoute',
|
|
23
|
+
tlsroutes: 'TLSRoute',
|
|
24
|
+
configmaps: 'ConfigMap',
|
|
25
|
+
secrets: 'Secret',
|
|
26
|
+
namespaces: 'Namespace',
|
|
27
|
+
events: 'Event',
|
|
28
|
+
nodes: 'Node',
|
|
29
|
+
jobs: 'Job',
|
|
30
|
+
cronjobs: 'CronJob',
|
|
31
|
+
horizontalpodautoscalers: 'HorizontalPodAutoscaler',
|
|
32
|
+
persistentvolumeclaims: 'PersistentVolumeClaim',
|
|
33
|
+
persistentvolumes: 'PersistentVolume',
|
|
34
|
+
storageclasses: 'StorageClass',
|
|
35
|
+
poddisruptionbudgets: 'PodDisruptionBudget',
|
|
36
|
+
rollouts: 'Rollout',
|
|
37
|
+
applications: 'Application',
|
|
38
|
+
kustomizations: 'Kustomization',
|
|
39
|
+
helmreleases: 'HelmRelease',
|
|
40
|
+
gitrepositories: 'GitRepository',
|
|
41
|
+
certificates: 'Certificate',
|
|
42
|
+
roles: 'Role',
|
|
43
|
+
clusterroles: 'ClusterRole',
|
|
44
|
+
rolebindings: 'RoleBinding',
|
|
45
|
+
clusterrolebindings: 'ClusterRoleBinding',
|
|
46
|
+
serviceaccounts: 'ServiceAccount',
|
|
47
|
+
networkpolicies: 'NetworkPolicy',
|
|
48
|
+
verticalpodautoscalers: 'VerticalPodAutoscaler',
|
|
49
|
+
virtualservices: 'VirtualService',
|
|
50
|
+
destinationrules: 'DestinationRule',
|
|
51
|
+
serviceentries: 'ServiceEntry',
|
|
52
|
+
peerauthentications: 'PeerAuthentication',
|
|
53
|
+
authorizationpolicies: 'AuthorizationPolicy',
|
|
54
|
+
}
|
|
55
|
+
|
|
9
56
|
/**
|
|
10
57
|
* Convert a singular kind (e.g., "Deployment") to plural API resource name (e.g., "deployments").
|
|
11
58
|
* Single source of truth — uses English pluralization rules with a small alias map for
|
|
12
59
|
* abbreviations and special mappings that aren't simple plurals.
|
|
60
|
+
* Idempotent: already-plural inputs (e.g., "secrets") are returned as-is.
|
|
13
61
|
*/
|
|
14
62
|
export function kindToPlural(kind: string): string {
|
|
15
63
|
const kindLower = kind.toLowerCase()
|
|
16
64
|
|
|
65
|
+
// Already a known plural — return as-is to prevent double-pluralization
|
|
66
|
+
if (kindLower in PLURAL_TO_KIND) return kindLower
|
|
67
|
+
|
|
17
68
|
// Aliases: abbreviations or mappings to a different resource name
|
|
18
69
|
const aliases: Record<string, string> = {
|
|
19
70
|
horizontalpodautoscaler: 'horizontalpodautoscalers',
|
|
@@ -40,53 +91,7 @@ export function kindToPlural(kind: string): string {
|
|
|
40
91
|
export function pluralToKind(plural: string): string {
|
|
41
92
|
const lower = plural.toLowerCase()
|
|
42
93
|
|
|
43
|
-
|
|
44
|
-
const reverseMap: Record<string, string> = {
|
|
45
|
-
pods: 'Pod',
|
|
46
|
-
services: 'Service',
|
|
47
|
-
deployments: 'Deployment',
|
|
48
|
-
daemonsets: 'DaemonSet',
|
|
49
|
-
statefulsets: 'StatefulSet',
|
|
50
|
-
replicasets: 'ReplicaSet',
|
|
51
|
-
ingresses: 'Ingress',
|
|
52
|
-
gateways: 'Gateway',
|
|
53
|
-
httproutes: 'HTTPRoute',
|
|
54
|
-
grpcroutes: 'GRPCRoute',
|
|
55
|
-
tcproutes: 'TCPRoute',
|
|
56
|
-
tlsroutes: 'TLSRoute',
|
|
57
|
-
configmaps: 'ConfigMap',
|
|
58
|
-
secrets: 'Secret',
|
|
59
|
-
namespaces: 'Namespace',
|
|
60
|
-
events: 'Event',
|
|
61
|
-
nodes: 'Node',
|
|
62
|
-
jobs: 'Job',
|
|
63
|
-
cronjobs: 'CronJob',
|
|
64
|
-
horizontalpodautoscalers: 'HorizontalPodAutoscaler',
|
|
65
|
-
persistentvolumeclaims: 'PersistentVolumeClaim',
|
|
66
|
-
persistentvolumes: 'PersistentVolume',
|
|
67
|
-
storageclasses: 'StorageClass',
|
|
68
|
-
poddisruptionbudgets: 'PodDisruptionBudget',
|
|
69
|
-
rollouts: 'Rollout',
|
|
70
|
-
applications: 'Application',
|
|
71
|
-
kustomizations: 'Kustomization',
|
|
72
|
-
helmreleases: 'HelmRelease',
|
|
73
|
-
gitrepositories: 'GitRepository',
|
|
74
|
-
certificates: 'Certificate',
|
|
75
|
-
roles: 'Role',
|
|
76
|
-
clusterroles: 'ClusterRole',
|
|
77
|
-
rolebindings: 'RoleBinding',
|
|
78
|
-
clusterrolebindings: 'ClusterRoleBinding',
|
|
79
|
-
serviceaccounts: 'ServiceAccount',
|
|
80
|
-
networkpolicies: 'NetworkPolicy',
|
|
81
|
-
verticalpodautoscalers: 'VerticalPodAutoscaler',
|
|
82
|
-
virtualservices: 'VirtualService',
|
|
83
|
-
destinationrules: 'DestinationRule',
|
|
84
|
-
serviceentries: 'ServiceEntry',
|
|
85
|
-
peerauthentications: 'PeerAuthentication',
|
|
86
|
-
authorizationpolicies: 'AuthorizationPolicy',
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (reverseMap[lower]) return reverseMap[lower]
|
|
94
|
+
if (PLURAL_TO_KIND[lower]) return PLURAL_TO_KIND[lower]
|
|
90
95
|
|
|
91
96
|
// If it already looks like a singular PascalCase kind (starts with uppercase), return as-is
|
|
92
97
|
if (plural[0] === plural[0].toUpperCase() && plural[0] !== plural[0].toLowerCase()) {
|