@skyhook-io/k8s-ui 1.4.0 → 1.4.2
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 +2 -2
- package/src/components/resources/ResourcesSidebar.tsx +152 -57
- package/src/components/resources/ResourcesView.tsx +141 -49
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
- package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
- package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +262 -45
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
- package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +7 -21
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +7 -56
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
- package/src/components/resources/renderers/PodRenderer.tsx +22 -74
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
- package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
- package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
- package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
- package/src/components/resources/resource-utils-cnpg.ts +47 -0
- package/src/components/resources/resource-utils-karpenter.ts +6 -0
- package/src/components/resources/resource-utils-prometheus.ts +50 -6
- package/src/components/resources/resource-utils.ts +131 -33
- package/src/components/shared/EditableYamlView.tsx +38 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +4 -4
- package/src/components/timeline/TimelineList.tsx +11 -4
- package/src/components/ui/Tooltip.tsx +16 -0
- package/src/components/ui/drawer-components.tsx +48 -1
- package/src/theme/variables.css +1 -1
- package/src/types/gitops.ts +8 -0
- package/src/utils/navigation.test.ts +29 -2
- package/src/utils/navigation.ts +45 -24
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react'
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
Copy,
|
|
4
4
|
CopyPlus,
|
|
@@ -91,6 +91,18 @@ function formatSaveError(error: string): { summary: string; details?: string } {
|
|
|
91
91
|
return { summary: error }
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// Safe sessionStorage wrappers — storage can throw QuotaExceededError or be
|
|
95
|
+
// blocked by browser security policies. Draft persistence is best-effort.
|
|
96
|
+
function safeSessionGet(key: string): string | null {
|
|
97
|
+
try { return sessionStorage.getItem(key) } catch { return null }
|
|
98
|
+
}
|
|
99
|
+
function safeSessionSet(key: string, value: string): void {
|
|
100
|
+
try { sessionStorage.setItem(key, value) } catch { /* best-effort */ }
|
|
101
|
+
}
|
|
102
|
+
function safeSessionRemove(key: string): void {
|
|
103
|
+
try { sessionStorage.removeItem(key) } catch { /* best-effort */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
94
106
|
interface EditableYamlViewProps {
|
|
95
107
|
resource: SelectedResource
|
|
96
108
|
data: any
|
|
@@ -109,11 +121,34 @@ interface EditableYamlViewProps {
|
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate }: EditableYamlViewProps) {
|
|
112
|
-
const
|
|
113
|
-
|
|
124
|
+
const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
|
|
125
|
+
|
|
126
|
+
// Restore draft from sessionStorage (e.g., after session-expiry redirect).
|
|
127
|
+
// All sessionStorage calls are wrapped in try-catch — storage can throw
|
|
128
|
+
// QuotaExceededError or be blocked by browser security policies.
|
|
129
|
+
const savedDraft = useRef(safeSessionGet(draftKey))
|
|
130
|
+
const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
|
|
131
|
+
const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
|
|
114
132
|
const [yamlErrors, setYamlErrors] = useState<string[]>([])
|
|
115
133
|
const [showErrorDetails, setShowErrorDetails] = useState(false)
|
|
116
134
|
|
|
135
|
+
// Clean up restored draft flag
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (typeof savedDraft.current === 'string') {
|
|
138
|
+
safeSessionRemove(draftKey)
|
|
139
|
+
savedDraft.current = null
|
|
140
|
+
}
|
|
141
|
+
}, [draftKey])
|
|
142
|
+
|
|
143
|
+
// Autosave draft to sessionStorage while editing (best-effort)
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (isEditing && editedYaml) {
|
|
146
|
+
safeSessionSet(draftKey, editedYaml)
|
|
147
|
+
} else {
|
|
148
|
+
safeSessionRemove(draftKey)
|
|
149
|
+
}
|
|
150
|
+
}, [isEditing, editedYaml, draftKey])
|
|
151
|
+
|
|
117
152
|
// Convert resource to YAML for editing
|
|
118
153
|
const convertToYaml = useCallback((d: any) => {
|
|
119
154
|
if (!d) return ''
|
|
@@ -353,7 +353,7 @@ export function ResourceRendererDispatch({
|
|
|
353
353
|
{kind === 'grpcroutes' && <GRPCRouteRenderer data={data} onNavigate={onNavigate} />}
|
|
354
354
|
{kind === 'tcproutes' && <SimpleRouteRenderer data={data} kind="TCPRoute" onNavigate={onNavigate} />}
|
|
355
355
|
{kind === 'tlsroutes' && <SimpleRouteRenderer data={data} kind="TLSRoute" onNavigate={onNavigate} />}
|
|
356
|
-
{kind === 'sealedsecrets' && <SealedSecretRenderer data={data} />}
|
|
356
|
+
{kind === 'sealedsecrets' && <SealedSecretRenderer data={data} onNavigate={onNavigate} />}
|
|
357
357
|
{kind === 'workflowtemplates' && <WorkflowTemplateRenderer data={data} />}
|
|
358
358
|
{(kind === 'networkpolicies' || kind === 'networkpolicy') && <NetworkPolicyRenderer data={data} />}
|
|
359
359
|
{(kind === 'ciliumnetworkpolicies' || kind === 'ciliumnetworkpolicy' || kind === 'ciliumclusterwidenetworkpolicies' || kind === 'ciliumclusterwidenetworkpolicy') && <CiliumNetworkPolicyRenderer data={data} />}
|
|
@@ -366,8 +366,8 @@ export function ResourceRendererDispatch({
|
|
|
366
366
|
{kind === 'gitrepositories' && <GitRepositoryRenderer data={data} />}
|
|
367
367
|
{kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
|
|
368
368
|
{kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
|
|
369
|
-
{kind === 'kustomizations' && <KustomizationRenderer data={data} />}
|
|
370
|
-
{kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} />}
|
|
369
|
+
{kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
|
|
370
|
+
{kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} onNavigate={onNavigate} />}
|
|
371
371
|
{kind === 'alerts' && <AlertRenderer data={data} />}
|
|
372
372
|
{kind === 'applications' && <ArgoApplicationRenderer data={data} />}
|
|
373
373
|
{kind === 'nodepools' && <KarpenterNodePoolRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -395,7 +395,7 @@ export function ResourceRendererDispatch({
|
|
|
395
395
|
{kind === 'backupstoragelocations' && <VeleroBSLRenderer data={data} />}
|
|
396
396
|
{kind === 'volumesnapshotlocations' && <VeleroVSLRenderer data={data} />}
|
|
397
397
|
{kind === 'externalsecrets' && <ExternalSecretRenderer data={data} onNavigate={onNavigate} />}
|
|
398
|
-
{kind === 'clusterexternalsecrets' && <ClusterExternalSecretRenderer data={data} />}
|
|
398
|
+
{kind === 'clusterexternalsecrets' && <ClusterExternalSecretRenderer data={data} onNavigate={onNavigate} />}
|
|
399
399
|
{(kind === 'secretstores' || kind === 'clustersecretstores') && <SecretStoreRenderer data={data} />}
|
|
400
400
|
{kind === 'clusters' && <CNPGClusterRenderer data={data} onNavigate={onNavigate} />}
|
|
401
401
|
{kind === 'scheduledbackups' && <CNPGScheduledBackupRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -603,10 +603,17 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick }: ActivityCar
|
|
|
603
603
|
{item.owner && (
|
|
604
604
|
<span className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
605
605
|
<span className="text-xs text-theme-text-quaternary">←</span>
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
606
|
+
{item.owner.kind === 'ReplicaSet' ? (
|
|
607
|
+
<ResourceRefBadge
|
|
608
|
+
resourceRef={{ kind: 'Deployment', namespace: item.namespace, name: item.owner.name.replace(/-[a-z0-9]+$/, '') }}
|
|
609
|
+
onClick={(ref) => onResourceClick?.(refToSelectedResource(ref))}
|
|
610
|
+
/>
|
|
611
|
+
) : (
|
|
612
|
+
<ResourceRefBadge
|
|
613
|
+
resourceRef={{ kind: item.owner.kind, namespace: item.namespace, name: item.owner.name }}
|
|
614
|
+
onClick={(ref) => onResourceClick?.(refToSelectedResource(ref))}
|
|
615
|
+
/>
|
|
616
|
+
)}
|
|
610
617
|
</span>
|
|
611
618
|
)}
|
|
612
619
|
{item.createdAt && (
|
|
@@ -109,6 +109,22 @@ export function Tooltip({
|
|
|
109
109
|
}
|
|
110
110
|
}, [])
|
|
111
111
|
|
|
112
|
+
// When disabled flips true, proactively cancel any pending show timer and
|
|
113
|
+
// clear visible state. Without this, a tooltip that was visible (or armed)
|
|
114
|
+
// when disabled became true would pop back on as soon as disabled flips
|
|
115
|
+
// false — even though the cursor is elsewhere and no fresh mouseenter has
|
|
116
|
+
// fired. Also covers the case where the trigger becomes unreachable via
|
|
117
|
+
// pointer-events-none and never fires mouseleave.
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (disabled) {
|
|
120
|
+
if (timeoutRef.current) {
|
|
121
|
+
clearTimeout(timeoutRef.current)
|
|
122
|
+
timeoutRef.current = null
|
|
123
|
+
}
|
|
124
|
+
setIsVisible(false)
|
|
125
|
+
}
|
|
126
|
+
}, [disabled])
|
|
127
|
+
|
|
112
128
|
if (disabled || !content) {
|
|
113
129
|
return <>{children}</>
|
|
114
130
|
}
|
|
@@ -28,6 +28,53 @@ export function KeyValueBadgeList({ items }: { items: Record<string, unknown> |
|
|
|
28
28
|
)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Renders a Kubernetes label selector (matchLabels + matchExpressions).
|
|
33
|
+
* Accepts either a full selector object `{ matchLabels?, matchExpressions? }`
|
|
34
|
+
* or a flat `Record<string, string>` (e.g. Service.spec.selector).
|
|
35
|
+
* Pass `inline` for flow-inline rendering (e.g. within a sentence).
|
|
36
|
+
*/
|
|
37
|
+
export function LabelSelectorDisplay({
|
|
38
|
+
selector,
|
|
39
|
+
emptyText = 'All',
|
|
40
|
+
inline = false,
|
|
41
|
+
}: {
|
|
42
|
+
selector: any
|
|
43
|
+
emptyText?: string
|
|
44
|
+
inline?: boolean
|
|
45
|
+
}) {
|
|
46
|
+
if (!selector) {
|
|
47
|
+
return <span className="text-xs text-theme-text-tertiary">{emptyText}</span>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Detect flat selector (e.g. Service.spec.selector has no matchLabels wrapper)
|
|
51
|
+
const isFlat = typeof selector === 'object' && !Array.isArray(selector) && !selector.matchLabels && !selector.matchExpressions
|
|
52
|
+
const matchLabels: Record<string, unknown> = isFlat ? selector : (selector.matchLabels || {})
|
|
53
|
+
const matchExpressions: Array<{ key: string; operator: string; values?: string[] }> = selector.matchExpressions || []
|
|
54
|
+
|
|
55
|
+
const hasLabels = Object.keys(matchLabels).length > 0
|
|
56
|
+
const hasExpressions = matchExpressions.length > 0
|
|
57
|
+
|
|
58
|
+
if (!hasLabels && !hasExpressions) {
|
|
59
|
+
return <span className="text-xs text-theme-text-tertiary">{emptyText}</span>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const Wrapper = inline ? 'span' : 'div'
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Wrapper className={inline ? 'inline-flex flex-wrap gap-1 align-middle' : 'flex flex-wrap gap-1'}>
|
|
66
|
+
{Object.entries(matchLabels).map(([k, v]) => (
|
|
67
|
+
<KeyValueBadge key={`l-${k}`} k={k} v={String(v)} />
|
|
68
|
+
))}
|
|
69
|
+
{matchExpressions.map((expr, i) => (
|
|
70
|
+
<span key={`e-${i}`} className="badge bg-theme-elevated text-theme-text-secondary">
|
|
71
|
+
{expr.key} {expr.operator}{expr.values && expr.values.length > 0 ? ` ${expr.values.join(', ')}` : ''}
|
|
72
|
+
</span>
|
|
73
|
+
))}
|
|
74
|
+
</Wrapper>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
31
78
|
interface SectionProps {
|
|
32
79
|
title: string
|
|
33
80
|
icon?: React.ComponentType<{ className?: string }>
|
|
@@ -234,7 +281,7 @@ export interface Problem {
|
|
|
234
281
|
message: string
|
|
235
282
|
}
|
|
236
283
|
|
|
237
|
-
/** Displays a list of problem alerts (warnings and errors)
|
|
284
|
+
/** Displays a list of problem alerts (warnings and errors) */
|
|
238
285
|
export function ProblemAlerts({ problems }: { problems: Problem[] }) {
|
|
239
286
|
if (problems.length === 0) return null
|
|
240
287
|
|
package/src/theme/variables.css
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
/* ── Text (cool neutral) ── */
|
|
44
44
|
--text-primary: light-dark(#1A1C20, #F5F6F8);
|
|
45
45
|
--text-secondary: light-dark(#555860, #D8DAE0);
|
|
46
|
-
--text-tertiary: light-dark(#
|
|
46
|
+
--text-tertiary: light-dark(#6B7080, #B0B5C0);
|
|
47
47
|
--text-disabled: light-dark(#A0A4AC, #6A7080);
|
|
48
48
|
|
|
49
49
|
/* ── Borders (lightly blue-tinted in light mode) ── */
|
package/src/types/gitops.ts
CHANGED
|
@@ -230,7 +230,9 @@ export interface ArgoAppStatus {
|
|
|
230
230
|
operationState?: {
|
|
231
231
|
phase?: ArgoOperationPhase | string // Known types + allow unknown
|
|
232
232
|
message?: string
|
|
233
|
+
startedAt?: string
|
|
233
234
|
finishedAt?: string
|
|
235
|
+
retryCount?: number
|
|
234
236
|
syncResult?: {
|
|
235
237
|
revision?: string
|
|
236
238
|
source?: ArgoSyncResultSource
|
|
@@ -238,6 +240,12 @@ export interface ArgoAppStatus {
|
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
reconciledAt?: string
|
|
243
|
+
history?: Array<{
|
|
244
|
+
revision?: string
|
|
245
|
+
deployedAt?: string
|
|
246
|
+
id?: number
|
|
247
|
+
source?: { repoURL?: string; path?: string }
|
|
248
|
+
}>
|
|
241
249
|
}
|
|
242
250
|
|
|
243
251
|
/**
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { describe, test, expect } from 'vitest'
|
|
2
|
-
import { kindToPlural, pluralToKind, refToSelectedResource } from './navigation'
|
|
1
|
+
import { describe, test, expect, afterEach } from 'vitest'
|
|
2
|
+
import { kindToPlural, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap } from './navigation'
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
resetNavigationMap()
|
|
6
|
+
})
|
|
3
7
|
|
|
4
8
|
describe('kindToPlural', () => {
|
|
5
9
|
test('singular PascalCase to plural lowercase', () => {
|
|
@@ -110,6 +114,29 @@ describe('pluralToKind', () => {
|
|
|
110
114
|
})
|
|
111
115
|
})
|
|
112
116
|
|
|
117
|
+
describe('initNavigationMap', () => {
|
|
118
|
+
test('discovered API resources override heuristic pluralization', () => {
|
|
119
|
+
// Before init, an unknown CRD would hit the heuristic fallback
|
|
120
|
+
// After init, it uses the discovered plural name
|
|
121
|
+
initNavigationMap([
|
|
122
|
+
{ group: 'external-secrets.io', version: 'v1', kind: 'SecretStore', name: 'secretstores', namespaced: true, isCrd: true, verbs: ['get'] },
|
|
123
|
+
{ group: 'external-secrets.io', version: 'v1', kind: 'ClusterSecretStore', name: 'clustersecretstores', namespaced: false, isCrd: true, verbs: ['get'] },
|
|
124
|
+
])
|
|
125
|
+
expect(kindToPlural('SecretStore')).toBe('secretstores')
|
|
126
|
+
expect(kindToPlural('ClusterSecretStore')).toBe('clustersecretstores')
|
|
127
|
+
expect(pluralToKind('secretstores')).toBe('SecretStore')
|
|
128
|
+
expect(pluralToKind('clustersecretstores')).toBe('ClusterSecretStore')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('prevents double-pluralization of discovered plurals', () => {
|
|
132
|
+
initNavigationMap([
|
|
133
|
+
{ group: 'external-secrets.io', version: 'v1', kind: 'SecretStore', name: 'secretstores', namespaced: true, isCrd: true, verbs: ['get'] },
|
|
134
|
+
])
|
|
135
|
+
// Passing an already-plural kind should be idempotent
|
|
136
|
+
expect(kindToPlural('secretstores')).toBe('secretstores')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
113
140
|
describe('refToSelectedResource', () => {
|
|
114
141
|
test('converts singular kind to plural for navigation', () => {
|
|
115
142
|
const result = refToSelectedResource({
|
package/src/utils/navigation.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SelectedResource, ResourceRef } from '../types/core'
|
|
1
|
+
import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Canonical callback type for navigating to a resource.
|
|
@@ -6,9 +6,8 @@ import type { SelectedResource, ResourceRef } from '../types/core'
|
|
|
6
6
|
*/
|
|
7
7
|
export type NavigateToResource = (resource: SelectedResource) => void
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
const PLURAL_TO_KIND: Record<string, string> = {
|
|
9
|
+
// Fallback map for core K8s resources — used before API discovery completes.
|
|
10
|
+
const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
|
|
12
11
|
pods: 'Pod',
|
|
13
12
|
services: 'Service',
|
|
14
13
|
deployments: 'Deployment',
|
|
@@ -16,11 +15,6 @@ const PLURAL_TO_KIND: Record<string, string> = {
|
|
|
16
15
|
statefulsets: 'StatefulSet',
|
|
17
16
|
replicasets: 'ReplicaSet',
|
|
18
17
|
ingresses: 'Ingress',
|
|
19
|
-
gateways: 'Gateway',
|
|
20
|
-
httproutes: 'HTTPRoute',
|
|
21
|
-
grpcroutes: 'GRPCRoute',
|
|
22
|
-
tcproutes: 'TCPRoute',
|
|
23
|
-
tlsroutes: 'TLSRoute',
|
|
24
18
|
configmaps: 'ConfigMap',
|
|
25
19
|
secrets: 'Secret',
|
|
26
20
|
namespaces: 'Namespace',
|
|
@@ -33,24 +27,44 @@ const PLURAL_TO_KIND: Record<string, string> = {
|
|
|
33
27
|
persistentvolumes: 'PersistentVolume',
|
|
34
28
|
storageclasses: 'StorageClass',
|
|
35
29
|
poddisruptionbudgets: 'PodDisruptionBudget',
|
|
36
|
-
rollouts: 'Rollout',
|
|
37
|
-
applications: 'Application',
|
|
38
|
-
kustomizations: 'Kustomization',
|
|
39
|
-
helmreleases: 'HelmRelease',
|
|
40
|
-
gitrepositories: 'GitRepository',
|
|
41
|
-
certificates: 'Certificate',
|
|
42
30
|
roles: 'Role',
|
|
43
31
|
clusterroles: 'ClusterRole',
|
|
44
32
|
rolebindings: 'RoleBinding',
|
|
45
33
|
clusterrolebindings: 'ClusterRoleBinding',
|
|
46
34
|
serviceaccounts: 'ServiceAccount',
|
|
47
35
|
networkpolicies: 'NetworkPolicy',
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Dynamic map built from API discovery — populated by initNavigationMap().
|
|
39
|
+
// Once populated, this is the source of truth for all kind↔plural lookups.
|
|
40
|
+
let discoveredPluralToKind: Record<string, string> | null = null
|
|
41
|
+
let discoveredKindToPlural: Record<string, string> | null = null
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Initialize navigation maps from discovered API resources.
|
|
45
|
+
* Call once when API resources are fetched. After this, kindToPlural/pluralToKind
|
|
46
|
+
* use the real cluster data instead of heuristics.
|
|
47
|
+
*/
|
|
48
|
+
export function initNavigationMap(resources: APIResource[]) {
|
|
49
|
+
const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
|
|
50
|
+
const k2p: Record<string, string> = {}
|
|
51
|
+
for (const r of resources) {
|
|
52
|
+
const plural = r.name.toLowerCase()
|
|
53
|
+
p2k[plural] = r.kind
|
|
54
|
+
k2p[r.kind.toLowerCase()] = plural
|
|
55
|
+
}
|
|
56
|
+
discoveredPluralToKind = p2k
|
|
57
|
+
discoveredKindToPlural = k2p
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reset navigation maps to builtin-only state. For testing. */
|
|
61
|
+
export function resetNavigationMap() {
|
|
62
|
+
discoveredPluralToKind = null
|
|
63
|
+
discoveredKindToPlural = null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getPluralToKind(): Record<string, string> {
|
|
67
|
+
return discoveredPluralToKind || BUILTIN_PLURAL_TO_KIND
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
/**
|
|
@@ -61,9 +75,15 @@ const PLURAL_TO_KIND: Record<string, string> = {
|
|
|
61
75
|
*/
|
|
62
76
|
export function kindToPlural(kind: string): string {
|
|
63
77
|
const kindLower = kind.toLowerCase()
|
|
78
|
+
const pluralToKindMap = getPluralToKind()
|
|
64
79
|
|
|
65
80
|
// Already a known plural — return as-is to prevent double-pluralization
|
|
66
|
-
if (kindLower in
|
|
81
|
+
if (kindLower in pluralToKindMap) return kindLower
|
|
82
|
+
|
|
83
|
+
// Lookup from discovered API resources (singular kind → plural name)
|
|
84
|
+
if (discoveredKindToPlural && kindLower in discoveredKindToPlural) {
|
|
85
|
+
return discoveredKindToPlural[kindLower]
|
|
86
|
+
}
|
|
67
87
|
|
|
68
88
|
// Aliases: abbreviations or mappings to a different resource name
|
|
69
89
|
const aliases: Record<string, string> = {
|
|
@@ -73,7 +93,7 @@ export function kindToPlural(kind: string): string {
|
|
|
73
93
|
}
|
|
74
94
|
if (aliases[kindLower]) return aliases[kindLower]
|
|
75
95
|
|
|
76
|
-
// English pluralization rules (covers *Class→*classes, *Policy→*policies, *Repository→*repositories, etc.)
|
|
96
|
+
// Fallback: English pluralization rules (covers *Class→*classes, *Policy→*policies, *Repository→*repositories, etc.)
|
|
77
97
|
if (kindLower.endsWith('s') || kindLower.endsWith('x') || kindLower.endsWith('ch') || kindLower.endsWith('sh')) {
|
|
78
98
|
return kindLower + 'es'
|
|
79
99
|
}
|
|
@@ -90,8 +110,9 @@ export function kindToPlural(kind: string): string {
|
|
|
90
110
|
*/
|
|
91
111
|
export function pluralToKind(plural: string): string {
|
|
92
112
|
const lower = plural.toLowerCase()
|
|
113
|
+
const pluralToKindMap = getPluralToKind()
|
|
93
114
|
|
|
94
|
-
if (
|
|
115
|
+
if (pluralToKindMap[lower]) return pluralToKindMap[lower]
|
|
95
116
|
|
|
96
117
|
// If it already looks like a singular PascalCase kind (starts with uppercase), return as-is
|
|
97
118
|
if (plural[0] === plural[0].toUpperCase() && plural[0] !== plural[0].toLowerCase()) {
|