@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.
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  export { Tooltip } from './Tooltip'
2
+ export { DialogPortal } from './DialogPortal'
2
3
  export { ConfirmDialog } from './ConfirmDialog'
3
4
  export { HealthRing } from './HealthRing'
4
5
  export { MetricsChart, MetricsSparkline } from './MetricsChart'
@@ -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
+ })
@@ -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
- // Explicit reverse mappings for irregular/aliased plurals
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()) {