@skyhook-io/k8s-ui 1.5.10 → 1.5.11

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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/components/dock/BottomDock.tsx +21 -10
  3. package/src/components/resources/get-pod-phase-display.test.ts +254 -0
  4. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +2 -4
  5. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +3 -2
  6. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -8
  7. package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -1
  8. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
  9. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -1
  10. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
  11. package/src/components/resources/renderers/GatewayClassRenderer.tsx +2 -1
  12. package/src/components/resources/renderers/GatewayRenderer.tsx +5 -4
  13. package/src/components/resources/renderers/IngressClassRenderer.tsx +2 -1
  14. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +2 -1
  15. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +3 -2
  16. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
  18. package/src/components/resources/renderers/OrderRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/PodRenderer.tsx +23 -2
  20. package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/RolloutRenderer.tsx +2 -1
  22. package/src/components/resources/renderers/SecretRenderer.tsx +15 -6
  23. package/src/components/resources/renderers/aws-capi-cells.tsx +2 -4
  24. package/src/components/resources/renderers/azure-capi-cells.tsx +3 -8
  25. package/src/components/resources/renderers/istio-cells.tsx +3 -2
  26. package/src/components/resources/renderers/karpenter-cells.tsx +1 -1
  27. package/src/components/resources/resource-utils-karpenter.ts +0 -6
  28. package/src/components/resources/resource-utils.ts +108 -0
  29. package/src/components/timeline/shared.tsx +5 -6
  30. package/src/components/ui/Tooltip.tsx +73 -40
  31. package/src/components/ui/tooltip-position.test.ts +132 -0
  32. package/src/components/ui/tooltip-position.ts +86 -0
  33. package/src/components/workload/WorkloadView.tsx +6 -6
  34. package/src/utils/badge-colors.ts +19 -0
  35. package/src/utils/index.ts +2 -0
  36. package/src/utils/validators.test.ts +207 -0
  37. package/src/utils/validators.ts +178 -0
  38. package/src/utils/view-transition.test.ts +81 -0
  39. package/src/utils/view-transition.ts +62 -0
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { computeTooltipPosition } from './tooltip-position'
3
+
4
+ const VIEWPORT = { width: 1280, height: 800 }
5
+
6
+ describe('computeTooltipPosition', () => {
7
+ it('returns null when the trigger has no layout (prevents 0,0 flash)', () => {
8
+ expect(
9
+ computeTooltipPosition({
10
+ triggerRect: { top: 0, left: 0, width: 0, height: 0 },
11
+ tooltipSize: { width: 80, height: 24 },
12
+ position: 'top',
13
+ viewport: VIEWPORT,
14
+ })
15
+ ).toBeNull()
16
+ })
17
+
18
+ it('returns a valid position even when the tooltip itself is unmeasured (size 0,0)', () => {
19
+ // Real situation on the very first show: tooltip portal exists but
20
+ // hasn't laid out yet, so its rect is (0,0,0,0). We still need a
21
+ // sane anchor so the next frame's update has something to refine —
22
+ // but it must NOT be (0,0).
23
+ const result = computeTooltipPosition({
24
+ triggerRect: { top: 12, left: 200, width: 80, height: 28 },
25
+ tooltipSize: { width: 0, height: 0 },
26
+ position: 'top',
27
+ viewport: VIEWPORT,
28
+ })
29
+ expect(result).not.toBeNull()
30
+ // Top placement above a near-the-edge trigger flips to bottom; either
31
+ // way the tooltip must not land at the viewport origin.
32
+ expect(result!.top).toBeGreaterThanOrEqual(8)
33
+ expect(result!.left).toBeGreaterThanOrEqual(8)
34
+ })
35
+
36
+ it('centers a top-placed tooltip horizontally above the trigger', () => {
37
+ const result = computeTooltipPosition({
38
+ triggerRect: { top: 200, left: 400, width: 100, height: 40 },
39
+ tooltipSize: { width: 80, height: 24 },
40
+ position: 'top',
41
+ viewport: VIEWPORT,
42
+ })
43
+ expect(result).toEqual({
44
+ // 200 - 24 - 6
45
+ top: 170,
46
+ // 400 + 50 - 40
47
+ left: 410,
48
+ })
49
+ })
50
+
51
+ it('flips a top-placed tooltip to bottom when the trigger is too close to the top edge (nav button case)', () => {
52
+ // Trigger near the top edge: "top" placement computes a negative top,
53
+ // so the helper must flip below.
54
+ const result = computeTooltipPosition({
55
+ triggerRect: { top: 12, left: 200, width: 80, height: 28 },
56
+ tooltipSize: { width: 80, height: 24 },
57
+ position: 'top',
58
+ viewport: VIEWPORT,
59
+ })
60
+ // 12 - 24 - 6 = -18 → flips to 12 + 28 + 6 = 46
61
+ expect(result!.top).toBe(46)
62
+ // Centered: 200 + 40 - 40 = 200
63
+ expect(result!.left).toBe(200)
64
+ })
65
+
66
+ it('clamps a tooltip that would overflow the right edge', () => {
67
+ const result = computeTooltipPosition({
68
+ triggerRect: { top: 100, left: 1240, width: 30, height: 28 },
69
+ tooltipSize: { width: 200, height: 24 },
70
+ position: 'top',
71
+ viewport: VIEWPORT,
72
+ })
73
+ // viewport.width - tooltipWidth - padding = 1280 - 200 - 8
74
+ expect(result!.left).toBe(1072)
75
+ })
76
+
77
+ it('clamps a tooltip that would overflow the left edge', () => {
78
+ const result = computeTooltipPosition({
79
+ triggerRect: { top: 100, left: 4, width: 30, height: 28 },
80
+ tooltipSize: { width: 200, height: 24 },
81
+ position: 'top',
82
+ viewport: VIEWPORT,
83
+ })
84
+ expect(result!.left).toBe(8)
85
+ })
86
+
87
+ it('places a bottom-anchored tooltip below the trigger', () => {
88
+ const result = computeTooltipPosition({
89
+ triggerRect: { top: 200, left: 400, width: 100, height: 40 },
90
+ tooltipSize: { width: 80, height: 24 },
91
+ position: 'bottom',
92
+ viewport: VIEWPORT,
93
+ })
94
+ expect(result!.top).toBe(246)
95
+ })
96
+
97
+ it('places a left-anchored tooltip to the left of the trigger and centers vertically', () => {
98
+ const result = computeTooltipPosition({
99
+ triggerRect: { top: 200, left: 400, width: 100, height: 40 },
100
+ tooltipSize: { width: 80, height: 24 },
101
+ position: 'left',
102
+ viewport: VIEWPORT,
103
+ })
104
+ expect(result).toEqual({
105
+ // 200 + 20 - 12
106
+ top: 208,
107
+ // 400 - 80 - 6
108
+ left: 314,
109
+ })
110
+ })
111
+
112
+ it('places a right-anchored tooltip to the right of the trigger', () => {
113
+ const result = computeTooltipPosition({
114
+ triggerRect: { top: 200, left: 400, width: 100, height: 40 },
115
+ tooltipSize: { width: 80, height: 24 },
116
+ position: 'right',
117
+ viewport: VIEWPORT,
118
+ })
119
+ expect(result!.left).toBe(506)
120
+ })
121
+
122
+ it('respects a custom padding', () => {
123
+ const result = computeTooltipPosition({
124
+ triggerRect: { top: 100, left: 4, width: 30, height: 28 },
125
+ tooltipSize: { width: 200, height: 24 },
126
+ position: 'top',
127
+ viewport: VIEWPORT,
128
+ padding: 20,
129
+ })
130
+ expect(result!.left).toBe(20)
131
+ })
132
+ })
@@ -0,0 +1,86 @@
1
+ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
2
+
3
+ export interface Rect {
4
+ top: number
5
+ left: number
6
+ width: number
7
+ height: number
8
+ }
9
+
10
+ export interface Size {
11
+ width: number
12
+ height: number
13
+ }
14
+
15
+ export interface Viewport {
16
+ width: number
17
+ height: number
18
+ }
19
+
20
+ export interface ComputeTooltipPositionInput {
21
+ triggerRect: Rect
22
+ tooltipSize: Size
23
+ position: TooltipPlacement
24
+ viewport: Viewport
25
+ padding?: number
26
+ }
27
+
28
+ /**
29
+ * Returns null when the trigger has no layout (zero rect at origin) so the
30
+ * caller can render hidden until a real measurement is available, instead
31
+ * of painting at (0, 0).
32
+ */
33
+ export function computeTooltipPosition({
34
+ triggerRect,
35
+ tooltipSize,
36
+ position,
37
+ viewport,
38
+ padding = 8,
39
+ }: ComputeTooltipPositionInput): { top: number; left: number } | null {
40
+ const triggerHasNoLayout =
41
+ triggerRect.width === 0 &&
42
+ triggerRect.height === 0 &&
43
+ triggerRect.top === 0 &&
44
+ triggerRect.left === 0
45
+ if (triggerHasNoLayout) {
46
+ return null
47
+ }
48
+
49
+ const { width: tw, height: th } = tooltipSize
50
+
51
+ let top = 0
52
+ let left = 0
53
+
54
+ switch (position) {
55
+ case 'top':
56
+ top = triggerRect.top - th - 6
57
+ left = triggerRect.left + triggerRect.width / 2 - tw / 2
58
+ break
59
+ case 'bottom':
60
+ top = triggerRect.top + triggerRect.height + 6
61
+ left = triggerRect.left + triggerRect.width / 2 - tw / 2
62
+ break
63
+ case 'left':
64
+ top = triggerRect.top + triggerRect.height / 2 - th / 2
65
+ left = triggerRect.left - tw - 6
66
+ break
67
+ case 'right':
68
+ top = triggerRect.top + triggerRect.height / 2 - th / 2
69
+ left = triggerRect.left + triggerRect.width + 6
70
+ break
71
+ }
72
+
73
+ if (left < padding) left = padding
74
+ if (left + tw > viewport.width - padding) {
75
+ left = viewport.width - tw - padding
76
+ }
77
+ if (top < padding) {
78
+ top = triggerRect.top + triggerRect.height + 6
79
+ }
80
+ if (top + th > viewport.height - padding) {
81
+ top = triggerRect.top - th - 6
82
+ }
83
+ if (top < padding) top = padding
84
+
85
+ return { top, left }
86
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
3
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
4
+ import { startViewTransitionSafe } from '../../utils/view-transition'
4
5
  import { PaneLoader } from '../ui/PaneLoader'
5
6
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
6
7
  import { clsx } from 'clsx'
@@ -212,12 +213,11 @@ export function WorkloadView({
212
213
  }, [kindProp, namespace, name, initialTab])
213
214
 
214
215
  const switchView = useCallback((yaml: boolean) => {
215
- const update = () => flushSync(() => setShowYaml(yaml))
216
- if (document.startViewTransition) {
217
- document.startViewTransition(update)
218
- } else {
219
- setShowYaml(yaml)
220
- }
216
+ // startViewTransitionSafe handles the API-missing fallback AND
217
+ // swallows the InvalidStateError that the API rejects with when
218
+ // a new transition supersedes an in-flight one (rapid clicks).
219
+ // (SKY-833 bug 49)
220
+ startViewTransitionSafe(() => flushSync(() => setShowYaml(yaml)))
221
221
  }, [])
222
222
 
223
223
  const [selectedEventId, setSelectedEventId] = useState<string | null>(null)
@@ -53,6 +53,25 @@ export const HELM_STATUS_COLORS: Record<string, string> = {
53
53
  uninstalled: BADGE_SEVERITY_COLORS.neutral,
54
54
  }
55
55
 
56
+ // Cloud instance capacity/priority badges — used by Karpenter NodeClaim and CAPI cloud-provider renderers
57
+ // Keyed by the value as it appears in the resource (spot, on-demand, regular, onDemand)
58
+ export const CAPACITY_TYPE_BADGE: Record<string, string> = {
59
+ spot: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
60
+ 'on-demand': 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
61
+ onDemand: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
62
+ regular: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
63
+ }
64
+
65
+ // AKS-style nodepool mode badges — System (control plane) vs User (workload)
66
+ export const NODEPOOL_MODE_BADGE: Record<string, string> = {
67
+ System: 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40',
68
+ User: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
69
+ }
70
+
71
+ // Translucent gray badge for "inactive / unknown / pending / unset / disabled" states.
72
+ // The de-facto fallback in renderers when no severity or category applies.
73
+ export const BADGE_INACTIVE = 'bg-gray-500/20 text-gray-400'
74
+
56
75
  // Best practices category colors
57
76
  export const BP_CATEGORY_BADGE: Record<string, string> = {
58
77
  Security: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/40 dark:text-purple-400 dark:border-purple-800/40',
@@ -12,3 +12,5 @@ export * from './api-resources'
12
12
  export * from './skeleton-yaml'
13
13
  export * from './k8s-errors'
14
14
  export * from './parse-go-time'
15
+ export * from './view-transition'
16
+ export * from './validators'
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ validateRFC1123Label,
4
+ validateRFC1123Subdomain,
5
+ validateHelmReleaseName,
6
+ validatePort,
7
+ } from './validators'
8
+
9
+ // Validation in user-facing forms is a silent-correctness-or-loud-error
10
+ // boundary: a regression here either lets bad input through to the API
11
+ // (helm install fails with a server-side 422 the user can't connect to
12
+ // their action) or rejects valid input (user is locked out of a
13
+ // legitimate name). Pin the canonical k8s / Helm rules so any drift
14
+ // fails the test instead of the user.
15
+
16
+ describe('validateRFC1123Label', () => {
17
+ it('accepts simple lowercase labels', () => {
18
+ expect(validateRFC1123Label('default').valid).toBe(true)
19
+ expect(validateRFC1123Label('my-app').valid).toBe(true)
20
+ expect(validateRFC1123Label('a').valid).toBe(true)
21
+ expect(validateRFC1123Label('a1').valid).toBe(true)
22
+ expect(validateRFC1123Label('1a').valid).toBe(true)
23
+ })
24
+
25
+ it('rejects empty input', () => {
26
+ const r = validateRFC1123Label('')
27
+ expect(r.valid).toBe(false)
28
+ if (!r.valid) expect(r.error).toMatch(/empty/)
29
+ })
30
+
31
+ it('rejects uppercase characters and special chars', () => {
32
+ const r = validateRFC1123Label('INVALID_NAME!@#')
33
+ expect(r.valid).toBe(false)
34
+ if (!r.valid) expect(r.error).toMatch(/lowercase/)
35
+ })
36
+
37
+ it('rejects underscores, spaces, and other special characters', () => {
38
+ expect(validateRFC1123Label('my_app').valid).toBe(false)
39
+ expect(validateRFC1123Label('my app').valid).toBe(false)
40
+ expect(validateRFC1123Label('my.app').valid).toBe(false)
41
+ expect(validateRFC1123Label('my!app').valid).toBe(false)
42
+ })
43
+
44
+ it('rejects names with leading or trailing hyphens', () => {
45
+ expect(validateRFC1123Label('-app').valid).toBe(false)
46
+ expect(validateRFC1123Label('app-').valid).toBe(false)
47
+ })
48
+
49
+ it('rejects names longer than 63 characters', () => {
50
+ const r = validateRFC1123Label('a'.repeat(64))
51
+ expect(r.valid).toBe(false)
52
+ if (!r.valid) expect(r.error).toMatch(/63/)
53
+ })
54
+
55
+ it('accepts a name exactly 63 characters long (boundary)', () => {
56
+ expect(validateRFC1123Label('a'.repeat(63)).valid).toBe(true)
57
+ })
58
+ })
59
+
60
+ describe('validateRFC1123Subdomain', () => {
61
+ it('accepts dotted subdomains', () => {
62
+ expect(validateRFC1123Subdomain('foo.bar').valid).toBe(true)
63
+ expect(validateRFC1123Subdomain('foo.bar.baz').valid).toBe(true)
64
+ expect(validateRFC1123Subdomain('foo').valid).toBe(true)
65
+ })
66
+
67
+ it('rejects names with consecutive dots or trailing dots', () => {
68
+ expect(validateRFC1123Subdomain('foo..bar').valid).toBe(false)
69
+ expect(validateRFC1123Subdomain('foo.').valid).toBe(false)
70
+ expect(validateRFC1123Subdomain('.foo').valid).toBe(false)
71
+ })
72
+
73
+ it('rejects uppercase', () => {
74
+ expect(validateRFC1123Subdomain('Foo.bar').valid).toBe(false)
75
+ })
76
+
77
+ it('rejects names longer than 253 characters', () => {
78
+ const seg = 'a'.repeat(60)
79
+ const tooLong = `${seg}.${seg}.${seg}.${seg}.${seg}` // 60*5 + 4 dots = 304
80
+ const r = validateRFC1123Subdomain(tooLong)
81
+ expect(r.valid).toBe(false)
82
+ if (!r.valid) expect(r.error).toMatch(/253/)
83
+ })
84
+
85
+ it('rejects single labels longer than 63 chars even when total is under 253', () => {
86
+ // K8s' IsDNS1123Subdomain enforces ≤ 63 chars per dot-
87
+ // separated label on top of the 253-char total. Without the
88
+ // per-label check, a single 200-char label slipped through
89
+ // client-side and was rejected only server-side.
90
+ const huge = 'a'.repeat(200)
91
+ const r = validateRFC1123Subdomain(huge)
92
+ expect(r.valid).toBe(false)
93
+ if (!r.valid) expect(r.error).toMatch(/63/)
94
+ })
95
+
96
+ it('accepts a 63-char label at the boundary', () => {
97
+ expect(validateRFC1123Subdomain('a'.repeat(63)).valid).toBe(true)
98
+ })
99
+
100
+ it('rejects only the offending label when other labels are fine', () => {
101
+ const r = validateRFC1123Subdomain(`ok.${'a'.repeat(64)}.also-ok`)
102
+ expect(r.valid).toBe(false)
103
+ if (!r.valid) expect(r.error).toMatch(/63/)
104
+ })
105
+ })
106
+
107
+ describe('validateHelmReleaseName', () => {
108
+ it('rejects names with spaces and special characters', () => {
109
+ const r = validateHelmReleaseName('Invalid Name With Spaces!')
110
+ expect(r.valid).toBe(false)
111
+ })
112
+
113
+ it('accepts legal release names', () => {
114
+ expect(validateHelmReleaseName('nginx').valid).toBe(true)
115
+ expect(validateHelmReleaseName('my-nginx-1').valid).toBe(true)
116
+ })
117
+
118
+ it('rejects release names longer than 53 characters', () => {
119
+ const r = validateHelmReleaseName('a'.repeat(54))
120
+ expect(r.valid).toBe(false)
121
+ if (!r.valid) expect(r.error).toMatch(/53/)
122
+ })
123
+
124
+ it('accepts a name exactly 53 characters long (boundary)', () => {
125
+ expect(validateHelmReleaseName('a'.repeat(53)).valid).toBe(true)
126
+ })
127
+
128
+ it('error string has no trailing period (call-site composition convention)', () => {
129
+ // ValidationResult contract: error strings have no trailing
130
+ // period — the caller composes them into sentences and adds
131
+ // its own. InstallWizard renders `Release name {error}.` so a
132
+ // trailing period in this error string produced "...limit.." in
133
+ // the UI when a user exceeded the cap.
134
+ const r = validateHelmReleaseName('a'.repeat(54))
135
+ if (r.valid) throw new Error('expected invalid')
136
+ expect(r.error.endsWith('.')).toBe(false)
137
+ })
138
+
139
+ it('rejects dotted names (generated resource names must be DNS-1123 labels)', () => {
140
+ // Helm itself permits dots, but generated resource names like
141
+ // `<release>-<chart>-<hash>` must be valid labels. Catch the dot
142
+ // up front instead of letting K8s reject the apply server-side.
143
+ expect(validateHelmReleaseName('my.app').valid).toBe(false)
144
+ expect(validateHelmReleaseName('foo.bar.baz').valid).toBe(false)
145
+ })
146
+ })
147
+
148
+ describe('validatePort', () => {
149
+ it('accepts ports in [1, 65535]', () => {
150
+ for (const p of [1, 80, 443, 8080, 65535]) {
151
+ const r = validatePort(p)
152
+ expect(r.valid).toBe(true)
153
+ if (r.valid) expect(r.value).toBe(p)
154
+ }
155
+ })
156
+
157
+ it('accepts string ports', () => {
158
+ const r = validatePort('8080')
159
+ expect(r.valid).toBe(true)
160
+ if (r.valid) expect(r.value).toBe(8080)
161
+ })
162
+
163
+ it('rejects 0', () => {
164
+ const r = validatePort('0')
165
+ expect(r.valid).toBe(false)
166
+ if (!r.valid) expect(r.error).toMatch(/0 is reserved|between 1/)
167
+ })
168
+
169
+ it('rejects negative numbers', () => {
170
+ const r = validatePort('-1')
171
+ expect(r.valid).toBe(false)
172
+ })
173
+
174
+ it('rejects ports above 65535', () => {
175
+ const r = validatePort('70000')
176
+ expect(r.valid).toBe(false)
177
+ if (!r.valid) expect(r.error).toMatch(/65535|between/)
178
+ })
179
+
180
+ it('rejects empty input', () => {
181
+ expect(validatePort('').valid).toBe(false)
182
+ })
183
+
184
+ it('rejects non-integer formats (decimals, scientific, signed, padded)', () => {
185
+ expect(validatePort('1.0').valid).toBe(false)
186
+ expect(validatePort('1e3').valid).toBe(false)
187
+ expect(validatePort('+1').valid).toBe(false)
188
+ expect(validatePort(' 80 ').valid).toBe(false)
189
+ expect(validatePort('80abc').valid).toBe(false)
190
+ })
191
+
192
+ it('does not silently truncate (the "70000 → 7000" maxlength bug)', () => {
193
+ // The browser's number input was effectively dropping the 5th digit
194
+ // on certain triple-select+type sequences. Our validator must NOT
195
+ // accept the truncated value silently — it must reject the full
196
+ // string the user typed.
197
+ const r = validatePort('70000')
198
+ expect(r.valid).toBe(false)
199
+ // Make sure we don't accidentally massage the value into 7000 just
200
+ // because that happens to be a valid port.
201
+ if (!r.valid) {
202
+ // No `value` field on the failure shape — guarantees we never
203
+ // pretend the user typed something else.
204
+ expect((r as unknown as { value?: number }).value).toBeUndefined()
205
+ }
206
+ })
207
+ })
@@ -0,0 +1,178 @@
1
+ // Pure validators for user-provided form inputs.
2
+ //
3
+ // Centralised here so the same rules apply to every form (Helm install
4
+ // release name, Audit Settings ignored namespaces, Port Forward local
5
+ // port, anything else that lands later). Reasons live next to the
6
+ // constraints so reviewers can sanity-check us against the underlying
7
+ // Kubernetes / Helm specs instead of folklore.
8
+
9
+ /**
10
+ * Result shape for all validators. `valid: true` is success; otherwise
11
+ * `error` is a short, user-readable explanation suitable for inline
12
+ * form feedback (no leading capital, no trailing period — call sites
13
+ * compose them into sentences).
14
+ */
15
+ export type ValidationResult =
16
+ | { valid: true }
17
+ | { valid: false; error: string }
18
+
19
+ const RFC1123_LABEL_MAX = 63
20
+ // Kubernetes namespace / pod / service names are RFC 1123 labels:
21
+ // lowercase alphanumeric or '-', start and end alphanumeric, ≤63 chars.
22
+ // Source: k8s.io/apimachinery/pkg/util/validation.IsDNS1123Label.
23
+ const RFC1123_LABEL_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/
24
+
25
+ const RFC1123_SUBDOMAIN_MAX = 253
26
+ // RFC 1123 subdomain = one or more labels joined by '.', total ≤253.
27
+ const RFC1123_SUBDOMAIN_RE =
28
+ /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/
29
+
30
+ const HELM_RELEASE_NAME_MAX = 53
31
+ // Helm release names: DNS subdomain plus the additional limit of 53
32
+ // chars so the generated resource names (`<release>-<chart>-<hash>`)
33
+ // fit under the 63-char label cap. Source:
34
+ // https://helm.sh/docs/chart_best_practices/conventions/#chart-names
35
+
36
+ /**
37
+ * Validates a Kubernetes RFC 1123 label. Used for namespace names,
38
+ * pod / service / configmap / secret names — anything Kubernetes
39
+ * accepts as a single-segment identifier.
40
+ */
41
+ export function validateRFC1123Label(input: string): ValidationResult {
42
+ if (input.length === 0) {
43
+ return { valid: false, error: 'must not be empty' }
44
+ }
45
+ if (input.length > RFC1123_LABEL_MAX) {
46
+ return {
47
+ valid: false,
48
+ error: `must be at most ${RFC1123_LABEL_MAX} characters (got ${input.length})`,
49
+ }
50
+ }
51
+ if (input !== input.toLowerCase()) {
52
+ return {
53
+ valid: false,
54
+ error: 'must be lowercase (Kubernetes names are case-sensitive and only allow [a-z0-9-])',
55
+ }
56
+ }
57
+ if (!RFC1123_LABEL_RE.test(input)) {
58
+ return {
59
+ valid: false,
60
+ error: 'must contain only lowercase letters, numbers, and hyphens, and start/end with a letter or number',
61
+ }
62
+ }
63
+ return { valid: true }
64
+ }
65
+
66
+ /**
67
+ * Validates a Kubernetes RFC 1123 subdomain (labels joined by dots).
68
+ * Used for Helm chart names, image registries — anything that allows
69
+ * dotted segments. NOT for pod/namespace names; use the label form.
70
+ */
71
+ export function validateRFC1123Subdomain(input: string): ValidationResult {
72
+ if (input.length === 0) {
73
+ return { valid: false, error: 'must not be empty' }
74
+ }
75
+ if (input.length > RFC1123_SUBDOMAIN_MAX) {
76
+ return {
77
+ valid: false,
78
+ error: `must be at most ${RFC1123_SUBDOMAIN_MAX} characters (got ${input.length})`,
79
+ }
80
+ }
81
+ if (input !== input.toLowerCase()) {
82
+ return {
83
+ valid: false,
84
+ error: 'must be lowercase',
85
+ }
86
+ }
87
+ if (!RFC1123_SUBDOMAIN_RE.test(input)) {
88
+ return {
89
+ valid: false,
90
+ error: 'must contain only lowercase letters, numbers, hyphens, and dots, and each segment must start/end with a letter or number',
91
+ }
92
+ }
93
+ // Per-label cap: K8s' IsDNS1123Subdomain enforces ≤ 63 chars
94
+ // PER dot-separated label on top of the 253-char total. The
95
+ // regex above doesn't catch that; without this check a single
96
+ // 200-char label passes here and only fails server-side.
97
+ for (const label of input.split('.')) {
98
+ if (label.length > RFC1123_LABEL_MAX) {
99
+ return {
100
+ valid: false,
101
+ error: `each dot-separated label must be at most ${RFC1123_LABEL_MAX} characters (got ${label.length} for "${label}")`,
102
+ }
103
+ }
104
+ }
105
+ return { valid: true }
106
+ }
107
+
108
+ /**
109
+ * Validates a Helm release name. Same shape as an RFC 1123 label
110
+ * (no dots) capped at 53 chars (Helm's hard limit; longer names
111
+ * produce resources that exceed K8s' 63-char label limit).
112
+ *
113
+ * Why label and not subdomain: Helm itself permits dots in release
114
+ * names, but most charts compose resource names as
115
+ * `<release>-<chart>-<hash>` which must be valid DNS-1123 labels.
116
+ * A dotted release name like `my.app` produces resources whose
117
+ * names fail K8s label validation server-side. Reject the dot up
118
+ * front so the user sees a clear inline error instead of a server
119
+ * 422 after submit.
120
+ */
121
+ export function validateHelmReleaseName(input: string): ValidationResult {
122
+ if (input.length === 0) {
123
+ return { valid: false, error: 'must not be empty' }
124
+ }
125
+ if (input.length > HELM_RELEASE_NAME_MAX) {
126
+ return {
127
+ valid: false,
128
+ // No trailing period — call sites compose this into a sentence
129
+ // and append their own.
130
+ error: `must be at most ${HELM_RELEASE_NAME_MAX} characters (got ${input.length}); Helm caps release names so derived resource names fit under K8s' 63-char limit`,
131
+ }
132
+ }
133
+ return validateRFC1123Label(input)
134
+ }
135
+
136
+ /**
137
+ * Validates a TCP/UDP port number provided as a free-text input.
138
+ *
139
+ * Accepts:
140
+ * - a number-like string with no whitespace, no leading zeros, no '+'
141
+ * - in [1, 65535]
142
+ *
143
+ * Rejects '0' (reserved), negative numbers, fractional numbers,
144
+ * scientific notation, and anything containing non-digit characters.
145
+ *
146
+ * Returns the parsed integer alongside the validation result so call
147
+ * sites don't have to re-parse.
148
+ */
149
+ export type PortValidationResult =
150
+ | { valid: true; value: number }
151
+ | { valid: false; error: string }
152
+
153
+ export function validatePort(input: string | number): PortValidationResult {
154
+ // Coerce a number argument to its decimal string form so the same
155
+ // strict rules (no fractional, no NaN) apply regardless of how the
156
+ // value reached us. We then re-parse to integer.
157
+ const raw = typeof input === 'number' ? String(input) : input
158
+ if (raw === '' || raw == null) {
159
+ return { valid: false, error: 'port is required' }
160
+ }
161
+ // Reject anything other than digits — '+1', '1.0', '1e3', ' 80 '
162
+ // all fail here. Catching them as parse failures gives clearer
163
+ // feedback than silently truncating via Number().
164
+ if (!/^\d+$/.test(raw)) {
165
+ return {
166
+ valid: false,
167
+ error: 'port must be a whole number between 1 and 65535',
168
+ }
169
+ }
170
+ const n = Number(raw)
171
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
172
+ return {
173
+ valid: false,
174
+ error: 'port must be between 1 and 65535 (0 is reserved)',
175
+ }
176
+ }
177
+ return { valid: true, value: n }
178
+ }