@skyhook-io/k8s-ui 1.5.10 → 1.5.12
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/audit/AuditFindingsTable.tsx +66 -30
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +65 -12
- package/src/components/dock/BottomDock.tsx +23 -12
- package/src/components/gitops/ManagedResourcesList.tsx +4 -2
- package/src/components/logs/LogCore.tsx +109 -14
- package/src/components/resources/ResourcesView.tsx +19 -2
- 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/KarpenterNodePoolRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +5 -5
- 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 +110 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +85 -0
- package/src/components/timeline/shared.tsx +5 -6
- package/src/components/topology/TopologySearch.tsx +49 -16
- package/src/components/topology/topology-search-match.test.ts +112 -0
- 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/ResourceDetailDrawer.tsx +3 -3
- package/src/components/workload/WorkloadView.tsx +6 -6
- package/src/types/core.ts +13 -1
- 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,112 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { TopologyNode } from '../../types'
|
|
3
|
+
|
|
4
|
+
// Mirrors the matching predicate inside TopologySearch (kept in sync
|
|
5
|
+
// by reuse) — pure so we can pin the matching rule without rendering
|
|
6
|
+
// the React component.
|
|
7
|
+
function matchesQuery(node: TopologyNode, lowerQuery: string): boolean {
|
|
8
|
+
const name = node.name.toLowerCase()
|
|
9
|
+
const kind = node.kind.toLowerCase()
|
|
10
|
+
const namespace = (node.data.namespace as string || '').toLowerCase()
|
|
11
|
+
return (
|
|
12
|
+
name.includes(lowerQuery) ||
|
|
13
|
+
kind.includes(lowerQuery) ||
|
|
14
|
+
namespace.includes(lowerQuery) ||
|
|
15
|
+
`${kind}/${name}`.includes(lowerQuery) ||
|
|
16
|
+
`${namespace}/${name}`.includes(lowerQuery)
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function countHidden(allNodes: TopologyNode[], visibleNodes: TopologyNode[], query: string): number {
|
|
21
|
+
if (!query.trim()) return 0
|
|
22
|
+
const lowerQuery = query.toLowerCase()
|
|
23
|
+
// First check whether the visible set already has matches — the
|
|
24
|
+
// hint should only appear when the visible search yielded zero.
|
|
25
|
+
const visibleMatches = visibleNodes.filter(n => matchesQuery(n, lowerQuery))
|
|
26
|
+
if (visibleMatches.length > 0) return 0
|
|
27
|
+
const visibleIds = new Set(visibleNodes.map(n => n.id))
|
|
28
|
+
return allNodes.filter(n => !visibleIds.has(n.id) && matchesQuery(n, lowerQuery)).length
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeNode(id: string, kind: string, name: string, namespace = 'default'): TopologyNode {
|
|
32
|
+
// Tests only need the four fields the matcher reads — cast through
|
|
33
|
+
// unknown so the partial shape doesn't have to satisfy the full
|
|
34
|
+
// TopologyNode type.
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
kind,
|
|
38
|
+
name,
|
|
39
|
+
data: { namespace },
|
|
40
|
+
} as unknown as TopologyNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('TopologySearch hidden-match counting (SKY-828 bug 45)', () => {
|
|
44
|
+
// The bug: in Fleet topology view (which only shows CAPI kinds),
|
|
45
|
+
// searching pod names returned "No resources found" — misleading
|
|
46
|
+
// when the cluster has 338 pods. We compute a hidden-match count
|
|
47
|
+
// so the empty-state can say "X matches are hidden by the current
|
|
48
|
+
// view; switch view to see them."
|
|
49
|
+
//
|
|
50
|
+
// The count should:
|
|
51
|
+
// 1. Be 0 when the visible set has any match (don't shame the
|
|
52
|
+
// visible empty-state).
|
|
53
|
+
// 2. Otherwise count nodes that match in the unfiltered set but
|
|
54
|
+
// are NOT in the visible set.
|
|
55
|
+
// 3. Match by name, kind, namespace, kind/name, or
|
|
56
|
+
// namespace/name (case-insensitive).
|
|
57
|
+
|
|
58
|
+
it('returns 0 when visible nodes contain a match', () => {
|
|
59
|
+
const visible = [makeNode('a', 'Cluster', 'prod')]
|
|
60
|
+
const all = [
|
|
61
|
+
...visible,
|
|
62
|
+
makeNode('b', 'Pod', 'prod-app'), // would also match "prod"
|
|
63
|
+
]
|
|
64
|
+
expect(countHidden(all, visible, 'prod')).toBe(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns count of hidden matches when visible has none (Fleet → pod search)', () => {
|
|
68
|
+
const visible = [
|
|
69
|
+
makeNode('cluster-1', 'Cluster', 'prod-cluster'),
|
|
70
|
+
makeNode('machine-1', 'Machine', 'prod-machine-1'),
|
|
71
|
+
]
|
|
72
|
+
const all = [
|
|
73
|
+
...visible,
|
|
74
|
+
makeNode('pod-1', 'Pod', '3scale-gateway-abc'),
|
|
75
|
+
makeNode('pod-2', 'Pod', '3scale-system-xyz'),
|
|
76
|
+
makeNode('pod-3', 'Pod', 'billing-api-789'),
|
|
77
|
+
]
|
|
78
|
+
expect(countHidden(all, visible, '3scale')).toBe(2)
|
|
79
|
+
expect(countHidden(all, visible, 'billing')).toBe(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('matches case-insensitively', () => {
|
|
83
|
+
const visible = [makeNode('a', 'Cluster', 'prod')]
|
|
84
|
+
const all = [...visible, makeNode('b', 'Pod', 'MyApp')]
|
|
85
|
+
expect(countHidden(all, visible, 'myapp')).toBe(1)
|
|
86
|
+
expect(countHidden(all, visible, 'MYAPP')).toBe(1)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('matches by namespace as well as name', () => {
|
|
90
|
+
const visible = [makeNode('a', 'Cluster', 'prod')]
|
|
91
|
+
const all = [
|
|
92
|
+
...visible,
|
|
93
|
+
makeNode('p1', 'Pod', 'frontend', 'billing'),
|
|
94
|
+
makeNode('p2', 'Pod', 'backend', 'billing'),
|
|
95
|
+
]
|
|
96
|
+
expect(countHidden(all, visible, 'billing')).toBe(2)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns 0 for an empty/whitespace query', () => {
|
|
100
|
+
const visible: TopologyNode[] = []
|
|
101
|
+
const all = [makeNode('a', 'Pod', 'foo')]
|
|
102
|
+
expect(countHidden(all, visible, '')).toBe(0)
|
|
103
|
+
expect(countHidden(all, visible, ' ')).toBe(0)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not double-count: a node visible AND matching is excluded from hidden', () => {
|
|
107
|
+
const visible = [makeNode('a', 'Pod', 'foo-1')]
|
|
108
|
+
const all = [...visible, makeNode('b', 'Pod', 'foo-2')]
|
|
109
|
+
// visible has a match → hint suppressed entirely
|
|
110
|
+
expect(countHidden(all, visible, 'foo')).toBe(0)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ReactNode, useState, useRef, useEffect, useCallback } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
+
import { computeTooltipPosition } from './tooltip-position'
|
|
4
5
|
|
|
5
6
|
// Module-level singleton coordinator: only one Tooltip can be visible
|
|
6
7
|
// at a time across the whole app. Without this, two Tooltip instances
|
|
@@ -45,53 +46,43 @@ export function Tooltip({
|
|
|
45
46
|
wrapperStyle,
|
|
46
47
|
}: TooltipProps) {
|
|
47
48
|
const [isVisible, setIsVisible] = useState(false)
|
|
48
|
-
|
|
49
|
+
// null until measured. Portal renders with visibility:hidden until coords
|
|
50
|
+
// resolve so the user never sees a frame painted at default (0,0).
|
|
51
|
+
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
|
|
52
|
+
null
|
|
53
|
+
)
|
|
49
54
|
const triggerRef = useRef<HTMLSpanElement>(null)
|
|
50
55
|
const tooltipRef = useRef<HTMLSpanElement>(null)
|
|
51
56
|
const timeoutRef = useRef<number | null>(null)
|
|
57
|
+
const rafRef = useRef<number | null>(null)
|
|
52
58
|
|
|
53
59
|
const updatePosition = useCallback(() => {
|
|
54
60
|
if (!triggerRef.current) return
|
|
55
61
|
|
|
56
|
-
const
|
|
62
|
+
const triggerRect = triggerRef.current.getBoundingClientRect()
|
|
57
63
|
const tooltipRect = tooltipRef.current?.getBoundingClientRect()
|
|
58
|
-
const tooltipWidth = tooltipRect?.width || 0
|
|
59
|
-
const tooltipHeight = tooltipRect?.height || 0
|
|
60
|
-
|
|
61
|
-
let top = 0
|
|
62
|
-
let left = 0
|
|
63
|
-
|
|
64
|
-
switch (position) {
|
|
65
|
-
case 'top':
|
|
66
|
-
top = rect.top - tooltipHeight - 6
|
|
67
|
-
left = rect.left + rect.width / 2 - tooltipWidth / 2
|
|
68
|
-
break
|
|
69
|
-
case 'bottom':
|
|
70
|
-
top = rect.bottom + 6
|
|
71
|
-
left = rect.left + rect.width / 2 - tooltipWidth / 2
|
|
72
|
-
break
|
|
73
|
-
case 'left':
|
|
74
|
-
top = rect.top + rect.height / 2 - tooltipHeight / 2
|
|
75
|
-
left = rect.left - tooltipWidth - 6
|
|
76
|
-
break
|
|
77
|
-
case 'right':
|
|
78
|
-
top = rect.top + rect.height / 2 - tooltipHeight / 2
|
|
79
|
-
left = rect.right + 6
|
|
80
|
-
break
|
|
81
|
-
}
|
|
82
64
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
65
|
+
const next = computeTooltipPosition({
|
|
66
|
+
triggerRect: {
|
|
67
|
+
top: triggerRect.top,
|
|
68
|
+
left: triggerRect.left,
|
|
69
|
+
width: triggerRect.width,
|
|
70
|
+
height: triggerRect.height,
|
|
71
|
+
},
|
|
72
|
+
tooltipSize: {
|
|
73
|
+
width: tooltipRect?.width ?? 0,
|
|
74
|
+
height: tooltipRect?.height ?? 0,
|
|
75
|
+
},
|
|
76
|
+
position,
|
|
77
|
+
viewport: {
|
|
78
|
+
width: window.innerWidth,
|
|
79
|
+
height: window.innerHeight,
|
|
80
|
+
},
|
|
81
|
+
})
|
|
93
82
|
|
|
94
|
-
|
|
83
|
+
if (next) {
|
|
84
|
+
setCoords(next)
|
|
85
|
+
}
|
|
95
86
|
}, [position])
|
|
96
87
|
|
|
97
88
|
// Stable hide function for the singleton registry — useRef so the
|
|
@@ -104,6 +95,7 @@ export function Tooltip({
|
|
|
104
95
|
timeoutRef.current = null
|
|
105
96
|
}
|
|
106
97
|
setIsVisible(false)
|
|
98
|
+
setCoords(null)
|
|
107
99
|
}
|
|
108
100
|
|
|
109
101
|
const showTooltip = () => {
|
|
@@ -128,12 +120,22 @@ export function Tooltip({
|
|
|
128
120
|
activeHide = null
|
|
129
121
|
}
|
|
130
122
|
setIsVisible(false)
|
|
123
|
+
setCoords(null)
|
|
131
124
|
}
|
|
132
125
|
|
|
133
126
|
useEffect(() => {
|
|
134
127
|
if (isVisible) {
|
|
135
|
-
//
|
|
136
|
-
|
|
128
|
+
// Second rAF re-centers using the measured tooltip size; without
|
|
129
|
+
// it the first frame centers against size 0 and visibly jumps.
|
|
130
|
+
const id1 = requestAnimationFrame(() => {
|
|
131
|
+
updatePosition()
|
|
132
|
+
const id2 = requestAnimationFrame(updatePosition)
|
|
133
|
+
rafRef.current = id2
|
|
134
|
+
})
|
|
135
|
+
rafRef.current = id1
|
|
136
|
+
return () => {
|
|
137
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
138
|
+
}
|
|
137
139
|
}
|
|
138
140
|
}, [isVisible, updatePosition])
|
|
139
141
|
|
|
@@ -142,6 +144,9 @@ export function Tooltip({
|
|
|
142
144
|
if (timeoutRef.current) {
|
|
143
145
|
clearTimeout(timeoutRef.current)
|
|
144
146
|
}
|
|
147
|
+
if (rafRef.current !== null) {
|
|
148
|
+
cancelAnimationFrame(rafRef.current)
|
|
149
|
+
}
|
|
145
150
|
// Clear from singleton registry on unmount — otherwise a Tooltip
|
|
146
151
|
// that unmounts while visible (e.g. row removed during hover)
|
|
147
152
|
// would leave a stale entry in activeHide pointing at a torn-down
|
|
@@ -165,9 +170,27 @@ export function Tooltip({
|
|
|
165
170
|
timeoutRef.current = null
|
|
166
171
|
}
|
|
167
172
|
setIsVisible(false)
|
|
173
|
+
setCoords(null)
|
|
168
174
|
}
|
|
169
175
|
}, [disabled])
|
|
170
176
|
|
|
177
|
+
// Mouseleave doesn't fire when the trigger stays mounted across a
|
|
178
|
+
// navigation and the cursor is still over it; popstate + Escape give
|
|
179
|
+
// a deterministic dismissal path for those cases.
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!isVisible) return
|
|
182
|
+
const onPop = () => hideRef.current()
|
|
183
|
+
const onKey = (e: KeyboardEvent) => {
|
|
184
|
+
if (e.key === 'Escape') hideRef.current()
|
|
185
|
+
}
|
|
186
|
+
window.addEventListener('popstate', onPop)
|
|
187
|
+
window.addEventListener('keydown', onKey)
|
|
188
|
+
return () => {
|
|
189
|
+
window.removeEventListener('popstate', onPop)
|
|
190
|
+
window.removeEventListener('keydown', onKey)
|
|
191
|
+
}
|
|
192
|
+
}, [isVisible])
|
|
193
|
+
|
|
171
194
|
if (disabled || !content) {
|
|
172
195
|
return <>{children}</>
|
|
173
196
|
}
|
|
@@ -182,6 +205,10 @@ export function Tooltip({
|
|
|
182
205
|
onMouseLeave={hideTooltip}
|
|
183
206
|
onFocus={showTooltip}
|
|
184
207
|
onBlur={hideTooltip}
|
|
208
|
+
// pointerdown fires before click, so the tooltip is gone before
|
|
209
|
+
// the trigger's action runs. Required when the child handles
|
|
210
|
+
// navigation/state changes that don't unmount the trigger.
|
|
211
|
+
onPointerDown={hideTooltip}
|
|
185
212
|
>
|
|
186
213
|
{children}
|
|
187
214
|
</span>
|
|
@@ -194,8 +221,14 @@ export function Tooltip({
|
|
|
194
221
|
'whitespace-nowrap pointer-events-none',
|
|
195
222
|
className
|
|
196
223
|
)}
|
|
197
|
-
style={{
|
|
224
|
+
style={{
|
|
225
|
+
top: coords?.top ?? 0,
|
|
226
|
+
left: coords?.left ?? 0,
|
|
227
|
+
// Hide until first measurement so we never paint at (0,0).
|
|
228
|
+
visibility: coords ? 'visible' : 'hidden',
|
|
229
|
+
}}
|
|
198
230
|
role="tooltip"
|
|
231
|
+
aria-hidden={coords ? undefined : true}
|
|
199
232
|
>
|
|
200
233
|
{content}
|
|
201
234
|
</span>,
|
|
@@ -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
|
+
}
|
|
@@ -115,7 +115,7 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
|
|
|
115
115
|
return (
|
|
116
116
|
<div
|
|
117
117
|
className={clsx(
|
|
118
|
-
'
|
|
118
|
+
'absolute right-0 bg-theme-surface border-l border-theme-border flex flex-col shadow-drawer z-40',
|
|
119
119
|
TRANSITION_DRAWER,
|
|
120
120
|
isOpen
|
|
121
121
|
? 'translate-x-0 opacity-100'
|
|
@@ -123,9 +123,9 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
|
|
|
123
123
|
expanded && '!border-l-0',
|
|
124
124
|
)}
|
|
125
125
|
style={{
|
|
126
|
-
width: expanded ? `calc(
|
|
126
|
+
width: expanded ? `calc(100% - ${leftOffset}px)` : drawerWidth,
|
|
127
127
|
top: headerHeight,
|
|
128
|
-
height: `calc(
|
|
128
|
+
height: `calc(100% - ${headerHeight}px)`,
|
|
129
129
|
// Collapse is instant — no animation, content and width snap together.
|
|
130
130
|
// Expand + slide-in/out animate via TRANSITION_DRAWER class.
|
|
131
131
|
...(isCollapsing && { transition: 'none' }),
|
|
@@ -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)
|
package/src/types/core.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface ResourcePermissions {
|
|
|
20
20
|
hpas: boolean
|
|
21
21
|
gateways: boolean
|
|
22
22
|
httpRoutes: boolean
|
|
23
|
+
roles: boolean
|
|
24
|
+
clusterRoles: boolean
|
|
25
|
+
roleBindings: boolean
|
|
26
|
+
clusterRoleBindings: boolean
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
// Feature capabilities based on RBAC permissions
|
|
@@ -329,6 +333,9 @@ export interface ContextInfo {
|
|
|
329
333
|
user: string
|
|
330
334
|
namespace: string
|
|
331
335
|
isCurrent: boolean
|
|
336
|
+
/** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by backend
|
|
337
|
+
* only when 2+ kubeconfig files are loaded; empty otherwise. */
|
|
338
|
+
source?: string
|
|
332
339
|
}
|
|
333
340
|
|
|
334
341
|
// Namespace
|
|
@@ -439,6 +446,8 @@ export interface APIResource {
|
|
|
439
446
|
export interface HelmRelease {
|
|
440
447
|
name: string
|
|
441
448
|
namespace: string
|
|
449
|
+
// Empty means Helm stores release metadata in namespace.
|
|
450
|
+
storageNamespace?: string
|
|
442
451
|
chart: string
|
|
443
452
|
chartVersion: string
|
|
444
453
|
appVersion: string
|
|
@@ -463,6 +472,8 @@ export interface HelmRevision {
|
|
|
463
472
|
export interface HelmReleaseDetail {
|
|
464
473
|
name: string
|
|
465
474
|
namespace: string
|
|
475
|
+
// Empty means Helm stores release metadata in namespace.
|
|
476
|
+
storageNamespace?: string
|
|
466
477
|
chart: string
|
|
467
478
|
chartVersion: string
|
|
468
479
|
appVersion: string
|
|
@@ -520,6 +531,7 @@ export interface ManifestDiff {
|
|
|
520
531
|
export interface SelectedHelmRelease {
|
|
521
532
|
namespace: string
|
|
522
533
|
name: string
|
|
534
|
+
storageNamespace?: string
|
|
523
535
|
}
|
|
524
536
|
|
|
525
537
|
// Upgrade availability info
|
|
@@ -531,7 +543,7 @@ export interface UpgradeInfo {
|
|
|
531
543
|
error?: string
|
|
532
544
|
}
|
|
533
545
|
|
|
534
|
-
// Batch upgrade info
|
|
546
|
+
// Batch upgrade info keyed by "storageNamespace/name".
|
|
535
547
|
export interface BatchUpgradeInfo {
|
|
536
548
|
releases: Record<string, UpgradeInfo>
|
|
537
549
|
}
|
|
@@ -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',
|