@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.
- package/package.json +1 -1
- package/src/components/dock/BottomDock.tsx +21 -10
- package/src/components/resources/get-pod-phase-display.test.ts +254 -0
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +2 -4
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -8
- package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -1
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +2 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +5 -4
- package/src/components/resources/renderers/IngressClassRenderer.tsx +2 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +2 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +3 -2
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
- package/src/components/resources/renderers/OrderRenderer.tsx +2 -1
- package/src/components/resources/renderers/PodRenderer.tsx +23 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -2
- package/src/components/resources/renderers/RolloutRenderer.tsx +2 -1
- package/src/components/resources/renderers/SecretRenderer.tsx +15 -6
- package/src/components/resources/renderers/aws-capi-cells.tsx +2 -4
- package/src/components/resources/renderers/azure-capi-cells.tsx +3 -8
- package/src/components/resources/renderers/istio-cells.tsx +3 -2
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -1
- package/src/components/resources/resource-utils-karpenter.ts +0 -6
- package/src/components/resources/resource-utils.ts +108 -0
- package/src/components/timeline/shared.tsx +5 -6
- package/src/components/ui/Tooltip.tsx +73 -40
- package/src/components/ui/tooltip-position.test.ts +132 -0
- package/src/components/ui/tooltip-position.ts +86 -0
- package/src/components/workload/WorkloadView.tsx +6 -6
- package/src/utils/badge-colors.ts +19 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/validators.test.ts +207 -0
- package/src/utils/validators.ts +178 -0
- package/src/utils/view-transition.test.ts +81 -0
- 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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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',
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|