@skyhook-io/k8s-ui 1.8.7 → 1.8.8
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 +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +4 -1
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsTableView.tsx +46 -45
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +41 -5
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/diagnostic.ts +22 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/types.ts +18 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
- package/src/components/namespace-switcher/index.ts +6 -0
- package/src/components/resources/ResourcesView.tsx +20 -81
- package/src/components/scope-pill/ScopePill.tsx +35 -0
- package/src/components/scope-pill/index.ts +2 -0
- package/src/components/timeline/TimelineList.tsx +27 -1
- package/src/components/topology/TopologyControls.tsx +90 -14
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +26 -8
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useKeyboardShortcuts.tsx +23 -2
- package/src/hooks/useRefreshAnimation.ts +15 -2
- package/src/index.ts +8 -0
- package/src/types/core.ts +42 -0
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -0
- package/src/utils/format-freshness.test.ts +34 -0
- package/src/utils/format.ts +32 -0
- package/src/utils/resource-hierarchy.test.ts +51 -0
- package/src/utils/resource-hierarchy.ts +7 -4
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
|
|
2
|
-
import {
|
|
1
|
+
import { useState, useCallback, useEffect, useLayoutEffect, useReducer, useRef, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
DURATION_DRAWER_MORPH,
|
|
4
|
+
EASE_DRAWER_MORPH,
|
|
5
|
+
} from '../../utils/animation'
|
|
3
6
|
import { clsx } from 'clsx'
|
|
4
7
|
import type { SelectedResource } from '../../types'
|
|
5
8
|
import { useDockReservedHeight } from '../dock/DockContext'
|
|
@@ -16,8 +19,13 @@ interface ResourceDetailDrawerProps {
|
|
|
16
19
|
expanded?: boolean
|
|
17
20
|
/** Called when user clicks collapse in expanded mode */
|
|
18
21
|
onCollapse?: () => void
|
|
19
|
-
/** Called when user clicks expand button
|
|
20
|
-
|
|
22
|
+
/** Called when user clicks expand button. `opts.yaml` is true when expanding
|
|
23
|
+
* from the drawer's YAML view, so the host can open the full view on YAML. */
|
|
24
|
+
onExpand?: (resource: SelectedResource, opts?: { yaml?: boolean }) => void
|
|
25
|
+
/** Whether the expanded view can collapse back to a side drawer. False on
|
|
26
|
+
* mobile (no room for a drawer) — the collapse-to-drawer control is hidden
|
|
27
|
+
* there and the host's onCollapse should close instead. Default true. */
|
|
28
|
+
canCollapseToDrawer?: boolean
|
|
21
29
|
/** Navigate to another resource within expanded WorkloadView */
|
|
22
30
|
onNavigateToResource?: (resource: SelectedResource) => void
|
|
23
31
|
/** Height of the host app's top navigation bar in px (default: 49) */
|
|
@@ -28,9 +36,16 @@ interface ResourceDetailDrawerProps {
|
|
|
28
36
|
children: (props: {
|
|
29
37
|
resource: SelectedResource
|
|
30
38
|
expanded: boolean
|
|
39
|
+
/** false on the outgoing layer mid-transition — suspend shortcuts/interaction */
|
|
40
|
+
active: boolean
|
|
31
41
|
initialTab?: 'detail' | 'yaml'
|
|
32
42
|
onClose: () => void
|
|
33
|
-
onExpand?: () => void
|
|
43
|
+
onExpand?: (opts?: { yaml?: boolean }) => void
|
|
44
|
+
/** Signal (hover/press the expand control) that expand is likely — pre-mounts
|
|
45
|
+
* the heavy fullscreen layer invisibly so the click starts the morph instantly. */
|
|
46
|
+
onExpandIntent?: () => void
|
|
47
|
+
/** Intent withdrawn (pointer left the expand control) — discard the pre-mount. */
|
|
48
|
+
onCancelExpandIntent?: () => void
|
|
34
49
|
onBack?: () => void
|
|
35
50
|
onNavigateToResource?: (resource: SelectedResource) => void
|
|
36
51
|
onCollapseToDrawer?: () => void
|
|
@@ -52,18 +67,101 @@ function getDefaultWidth(kind: string): number {
|
|
|
52
67
|
return WIDE_KINDS.has(kind.toLowerCase()) ? WIDE_WIDTH : DEFAULT_WIDTH
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
|
|
70
|
+
function usePrefersReducedMotion(): boolean {
|
|
71
|
+
const [reduced, setReduced] = useState(
|
|
72
|
+
() => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
|
73
|
+
)
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
76
|
+
if (!mq) return
|
|
77
|
+
const onChange = () => setReduced(mq.matches)
|
|
78
|
+
mq.addEventListener('change', onChange)
|
|
79
|
+
return () => mq.removeEventListener('change', onChange)
|
|
80
|
+
}, [])
|
|
81
|
+
return reduced
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab, isOpen = true, expanded, onCollapse, onExpand, canCollapseToDrawer = true, onNavigateToResource, headerHeight: headerHeightProp, leftOffset = 0, children }: ResourceDetailDrawerProps) {
|
|
56
85
|
const [drawerWidth, setDrawerWidth] = useState(() => getDefaultWidth(resource.kind))
|
|
57
86
|
const [isResizing, setIsResizing] = useState(false)
|
|
58
87
|
const resizeStartX = useRef(0)
|
|
59
88
|
const resizeStartWidth = useRef(getDefaultWidth(resource.kind))
|
|
89
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
90
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
91
|
+
|
|
92
|
+
// Expand/collapse crossfade. During the window we mount BOTH the drawer and
|
|
93
|
+
// full-screen layouts, each pinned to its own width (so neither reflows or
|
|
94
|
+
// squashes), and crossfade opacity while the container width (the frame)
|
|
95
|
+
// animates between them.
|
|
96
|
+
//
|
|
97
|
+
// `settledExpanded` is the last finished state. While it differs from the
|
|
98
|
+
// incoming `expanded` prop we're mid-transition: render both layers, fade
|
|
99
|
+
// `crossfadeArmed` 0→1 once a start frame has painted, then settle.
|
|
100
|
+
const settledExpanded = useRef(!!expanded)
|
|
101
|
+
const [crossfadeArmed, setCrossfadeArmed] = useState(false)
|
|
102
|
+
const [fullWidthPx, setFullWidthPx] = useState<number | null>(null)
|
|
103
|
+
const [, forceSettle] = useReducer((c: number) => c + 1, 0)
|
|
104
|
+
const transitioning = settledExpanded.current !== !!expanded
|
|
60
105
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
106
|
+
// Pre-mount: hovering/pressing the expand control mounts the heavy fullscreen
|
|
107
|
+
// layer invisibly ahead of the click, so the click reuses it (same key) and the
|
|
108
|
+
// morph's first frames aren't competing with the layer's mount cost.
|
|
109
|
+
const [prewarmExpand, setPrewarmExpand] = useState(false)
|
|
110
|
+
|
|
111
|
+
const measureFullWidth = useCallback(() => {
|
|
112
|
+
const el = containerRef.current
|
|
113
|
+
const parent = el?.offsetParent as HTMLElement | null
|
|
114
|
+
const measured = parent ? parent.clientWidth - leftOffset : el?.clientWidth
|
|
115
|
+
if (measured && measured > 0) setFullWidthPx(measured)
|
|
116
|
+
}, [leftOffset])
|
|
117
|
+
|
|
118
|
+
const handleExpandIntent = useCallback(() => {
|
|
119
|
+
if (expanded || transitioning) return
|
|
120
|
+
measureFullWidth()
|
|
121
|
+
setPrewarmExpand(true)
|
|
122
|
+
}, [expanded, transitioning, measureFullWidth])
|
|
123
|
+
|
|
124
|
+
const handleCancelExpandIntent = useCallback(() => {
|
|
125
|
+
if (transitioning || expanded) return
|
|
126
|
+
setPrewarmExpand(false)
|
|
127
|
+
}, [transitioning, expanded])
|
|
128
|
+
|
|
129
|
+
useLayoutEffect(() => {
|
|
130
|
+
if (settledExpanded.current === !!expanded) return
|
|
131
|
+
if (prefersReducedMotion) {
|
|
132
|
+
settledExpanded.current = !!expanded
|
|
133
|
+
setPrewarmExpand(false)
|
|
134
|
+
forceSettle()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
// Pin the full-screen layer to the measured expanded width so its content
|
|
138
|
+
// lays out at its final width from the first frame (no live reflow).
|
|
139
|
+
measureFullWidth()
|
|
140
|
+
// The prewarm layer (if any) becomes the real transition layer now (reused by key).
|
|
141
|
+
setPrewarmExpand(false)
|
|
142
|
+
// Double-rAF so the incoming layer's mount+paint lands BEFORE the motion starts
|
|
143
|
+
// (else it drops frames in the first third). Prewarm keeps the heavy mount off
|
|
144
|
+
// the click path; these two frames are then cheap (the layer's already there).
|
|
145
|
+
setCrossfadeArmed(false)
|
|
146
|
+
let raf2 = 0
|
|
147
|
+
let timer = 0
|
|
148
|
+
const arm = () => {
|
|
149
|
+
setCrossfadeArmed(true)
|
|
150
|
+
timer = window.setTimeout(() => {
|
|
151
|
+
settledExpanded.current = !!expanded
|
|
152
|
+
setCrossfadeArmed(false)
|
|
153
|
+
forceSettle()
|
|
154
|
+
}, DURATION_DRAWER_MORPH)
|
|
155
|
+
}
|
|
156
|
+
const raf1 = requestAnimationFrame(() => {
|
|
157
|
+
raf2 = requestAnimationFrame(arm)
|
|
158
|
+
})
|
|
159
|
+
return () => {
|
|
160
|
+
cancelAnimationFrame(raf1)
|
|
161
|
+
cancelAnimationFrame(raf2)
|
|
162
|
+
clearTimeout(timer)
|
|
163
|
+
}
|
|
164
|
+
}, [expanded, prefersReducedMotion, measureFullWidth])
|
|
67
165
|
|
|
68
166
|
// Reset drawer width when resource kind changes
|
|
69
167
|
useEffect(() => {
|
|
@@ -72,14 +170,20 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
|
|
|
72
170
|
resizeStartWidth.current = w
|
|
73
171
|
}, [resource.kind])
|
|
74
172
|
|
|
75
|
-
//
|
|
173
|
+
// Drop a pending prewarm if the drawer closes or the target resource changes —
|
|
174
|
+
// its hover intent is stale, and a reopened drawer shouldn't pre-mount unbidden.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
setPrewarmExpand(false)
|
|
177
|
+
}, [isOpen, resource.kind, resource.namespace, resource.name])
|
|
178
|
+
|
|
179
|
+
// Resize handlers (disabled when expanded or mid-transition)
|
|
76
180
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
77
|
-
if (expanded) return
|
|
181
|
+
if (expanded || transitioning) return
|
|
78
182
|
e.preventDefault()
|
|
79
183
|
setIsResizing(true)
|
|
80
184
|
resizeStartX.current = e.clientX
|
|
81
185
|
resizeStartWidth.current = drawerWidth
|
|
82
|
-
}, [drawerWidth, expanded])
|
|
186
|
+
}, [drawerWidth, expanded, transitioning])
|
|
83
187
|
|
|
84
188
|
useEffect(() => {
|
|
85
189
|
if (!isResizing) return
|
|
@@ -114,27 +218,72 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
|
|
|
114
218
|
const headerHeight = headerHeightProp ?? 49
|
|
115
219
|
const dockInset = useDockReservedHeight()
|
|
116
220
|
|
|
221
|
+
// Width animates during expand/collapse, but snaps during manual resize and
|
|
222
|
+
// when the user prefers reduced motion.
|
|
223
|
+
const animateWidth = !isResizing && !prefersReducedMotion
|
|
224
|
+
|
|
225
|
+
// During the pre-mount frames (transitioning but not yet armed) hold the frame
|
|
226
|
+
// at its START width so the width transition fires only once the incoming
|
|
227
|
+
// layer has painted; otherwise it snaps to the target.
|
|
228
|
+
const fullW = `calc(100% - ${leftOffset}px)`
|
|
229
|
+
const startWidth = settledExpanded.current ? fullW : drawerWidth
|
|
230
|
+
const targetWidth = expanded ? fullW : drawerWidth
|
|
231
|
+
const containerWidth = transitioning && !crossfadeArmed ? startWidth : targetWidth
|
|
232
|
+
|
|
233
|
+
const renderLayer = (layerExpanded: boolean, active: boolean) =>
|
|
234
|
+
children({
|
|
235
|
+
resource,
|
|
236
|
+
expanded: layerExpanded,
|
|
237
|
+
active,
|
|
238
|
+
initialTab,
|
|
239
|
+
onClose,
|
|
240
|
+
onExpand: onExpand ? (opts) => onExpand(resource, opts) : undefined,
|
|
241
|
+
onExpandIntent: onExpand ? handleExpandIntent : undefined,
|
|
242
|
+
onCancelExpandIntent: onExpand ? handleCancelExpandIntent : undefined,
|
|
243
|
+
onBack: onCollapse ? () => onCollapse() : undefined,
|
|
244
|
+
onNavigateToResource: handleNavigate,
|
|
245
|
+
// Hidden on mobile (no drawer to collapse to) — the host routes the
|
|
246
|
+
// back/close control through onCollapse instead.
|
|
247
|
+
onCollapseToDrawer: (canCollapseToDrawer && onCollapse) ? () => onCollapse() : undefined,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// While transitioning render both layers (outgoing = settled state, incoming
|
|
251
|
+
// = target). Keying by expanded-ness keeps the incoming layer's React
|
|
252
|
+
// identity stable into idle, so it survives (state intact) while only the
|
|
253
|
+
// outgoing layer unmounts when the window ends. While prewarming (collapsed,
|
|
254
|
+
// intent signalled) also mount the expanded layer invisibly — keyed 'expanded'
|
|
255
|
+
// so it's reused (not remounted) when the real expand starts.
|
|
256
|
+
const showPrewarmLayer = prewarmExpand && isOpen && !transitioning && !expanded
|
|
257
|
+
const layerExpandedValues = transitioning ? [true, false] : showPrewarmLayer ? [false, true] : [!!expanded]
|
|
258
|
+
|
|
117
259
|
return (
|
|
118
260
|
<div
|
|
261
|
+
ref={containerRef}
|
|
119
262
|
className={clsx(
|
|
120
|
-
'absolute right-0 bg-theme-surface border-l border-theme-border flex flex-col
|
|
121
|
-
|
|
263
|
+
'absolute right-0 bg-theme-surface border-l border-theme-border flex flex-col z-40',
|
|
264
|
+
// Clip the wider layer only while morphing; keep overflow visible at idle
|
|
265
|
+
// so drawer popovers/tooltips aren't clipped.
|
|
266
|
+
transitioning && 'overflow-hidden',
|
|
267
|
+
// No drawer shadow in fullscreen — it's a full page, not a floating panel.
|
|
268
|
+
!expanded && 'shadow-drawer',
|
|
122
269
|
isOpen
|
|
123
270
|
? 'translate-x-0 opacity-100'
|
|
124
271
|
: 'translate-x-full opacity-0',
|
|
125
272
|
expanded && '!border-l-0',
|
|
126
273
|
)}
|
|
127
274
|
style={{
|
|
128
|
-
width:
|
|
275
|
+
width: containerWidth,
|
|
129
276
|
top: headerHeight,
|
|
130
277
|
height: `calc(100% - ${headerHeight}px - ${dockInset}px)`,
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
278
|
+
// Inline so the morph duration/easing is controlled precisely (and stays
|
|
279
|
+
// in lockstep with the crossfade + JS window). Width only animates when
|
|
280
|
+
// not resizing / reduced-motion.
|
|
281
|
+
transition: `translate ${DURATION_DRAWER_MORPH}ms ${EASE_DRAWER_MORPH}, opacity ${DURATION_DRAWER_MORPH}ms ${EASE_DRAWER_MORPH}${animateWidth ? `, width ${DURATION_DRAWER_MORPH}ms ${EASE_DRAWER_MORPH}` : ''}`,
|
|
282
|
+
willChange: 'transform, width',
|
|
134
283
|
}}
|
|
135
284
|
>
|
|
136
|
-
{/* Resize handle — hidden when expanded or on mobile */}
|
|
137
|
-
{!expanded && (
|
|
285
|
+
{/* Resize handle — hidden when expanded or mid-transition or on mobile */}
|
|
286
|
+
{!expanded && !transitioning && (
|
|
138
287
|
<div
|
|
139
288
|
onMouseDown={handleResizeStart}
|
|
140
289
|
className={clsx(
|
|
@@ -145,15 +294,49 @@ export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab
|
|
|
145
294
|
/>
|
|
146
295
|
)}
|
|
147
296
|
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
297
|
+
{layerExpandedValues.map((layerExpanded) => {
|
|
298
|
+
if (!transitioning) {
|
|
299
|
+
// Prewarm: the expanded layer mounted invisibly ahead of the click.
|
|
300
|
+
// Pinned at full width + inert + opacity 0 so its mount cost is paid now,
|
|
301
|
+
// off-screen, and it's reused (same key) when the morph actually starts.
|
|
302
|
+
if (showPrewarmLayer && layerExpanded) {
|
|
303
|
+
return (
|
|
304
|
+
<div
|
|
305
|
+
key="expanded"
|
|
306
|
+
aria-hidden
|
|
307
|
+
inert
|
|
308
|
+
className="absolute top-0 bottom-0 right-0 overflow-hidden pointer-events-none opacity-0"
|
|
309
|
+
style={{ width: fullWidthPx ?? `calc(100% - ${leftOffset}px)` }}
|
|
310
|
+
>
|
|
311
|
+
{renderLayer(true, false)}
|
|
312
|
+
</div>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
// Idle (or the visible collapsed layer during prewarm): fills the container.
|
|
316
|
+
return (
|
|
317
|
+
<div key={layerExpanded ? 'expanded' : 'collapsed'} className="absolute inset-0">
|
|
318
|
+
{renderLayer(layerExpanded, true)}
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
const isIncoming = layerExpanded === !!expanded
|
|
323
|
+
return (
|
|
324
|
+
<div
|
|
325
|
+
key={layerExpanded ? 'expanded' : 'collapsed'}
|
|
326
|
+
className="absolute top-0 bottom-0 right-0 overflow-hidden pointer-events-none"
|
|
327
|
+
style={{
|
|
328
|
+
width: layerExpanded ? (fullWidthPx ?? `calc(100% - ${leftOffset}px)`) : drawerWidth,
|
|
329
|
+
opacity: isIncoming ? (crossfadeArmed ? 1 : 0) : (crossfadeArmed ? 0 : 1),
|
|
330
|
+
transition: `opacity ${DURATION_DRAWER_MORPH}ms ${EASE_DRAWER_MORPH}`,
|
|
331
|
+
willChange: 'opacity',
|
|
332
|
+
}}
|
|
333
|
+
aria-hidden={!isIncoming}
|
|
334
|
+
>
|
|
335
|
+
{/* Both layers inactive mid-crossfade — shortcuts shouldn't dispatch
|
|
336
|
+
during the animation; the settled layer owns them once idle. */}
|
|
337
|
+
{renderLayer(layerExpanded, false)}
|
|
338
|
+
</div>
|
|
339
|
+
)
|
|
157
340
|
})}
|
|
158
341
|
</div>
|
|
159
342
|
)
|
|
@@ -71,10 +71,17 @@ interface WorkloadViewProps {
|
|
|
71
71
|
onCollapseToDrawer?: () => void
|
|
72
72
|
/** false = collapsed drawer mode, true (default) = full expanded mode */
|
|
73
73
|
expanded?: boolean
|
|
74
|
+
/** false on the outgoing layer during an expand/collapse crossfade — suspend
|
|
75
|
+
* keyboard shortcuts so the invisible layer doesn't capture them (default true) */
|
|
76
|
+
active?: boolean
|
|
74
77
|
/** Close the drawer (collapsed mode) */
|
|
75
78
|
onClose?: () => void
|
|
76
|
-
/** Expand from drawer to full view
|
|
77
|
-
|
|
79
|
+
/** Expand from drawer to full view. `opts.yaml` true when expanding from the
|
|
80
|
+
* drawer's YAML view so the full view opens on the YAML tab (edits carry over). */
|
|
81
|
+
onExpand?: (opts?: { yaml?: boolean }) => void
|
|
82
|
+
/** Hover/press the expand control = likely expand → pre-mount the full view. */
|
|
83
|
+
onExpandIntent?: () => void
|
|
84
|
+
onCancelExpandIntent?: () => void
|
|
78
85
|
/** Initial view tab — 'yaml' opens YAML directly */
|
|
79
86
|
initialTab?: 'detail' | 'yaml'
|
|
80
87
|
/** API group for CRD resources */
|
|
@@ -248,8 +255,11 @@ export function WorkloadView({
|
|
|
248
255
|
onNavigateToResource,
|
|
249
256
|
onCollapseToDrawer,
|
|
250
257
|
expanded = true,
|
|
258
|
+
active = true,
|
|
251
259
|
onClose,
|
|
252
260
|
onExpand,
|
|
261
|
+
onExpandIntent,
|
|
262
|
+
onCancelExpandIntent,
|
|
253
263
|
initialTab,
|
|
254
264
|
group,
|
|
255
265
|
breadcrumb,
|
|
@@ -524,9 +534,12 @@ export function WorkloadView({
|
|
|
524
534
|
keys: 'Escape',
|
|
525
535
|
description: expanded ? 'Go back' : 'Close drawer',
|
|
526
536
|
category: expanded ? 'Navigation' as const : 'Drawer' as const,
|
|
527
|
-
|
|
537
|
+
// 'drawer' (top priority) in both modes so when this is the fullscreen
|
|
538
|
+
// overlay its Escape unambiguously wins over any background view's Escape
|
|
539
|
+
// (incl. another 'global'-scope WorkloadView mounted underneath).
|
|
540
|
+
scope: 'drawer' as const,
|
|
528
541
|
handler: expanded ? onBack : () => onClose?.(),
|
|
529
|
-
enabled:
|
|
542
|
+
enabled: active,
|
|
530
543
|
},
|
|
531
544
|
{
|
|
532
545
|
id: 'drawer-yaml',
|
|
@@ -535,7 +548,7 @@ export function WorkloadView({
|
|
|
535
548
|
category: 'Drawer' as const,
|
|
536
549
|
scope: 'drawer' as const,
|
|
537
550
|
handler: () => switchView(true),
|
|
538
|
-
enabled: !expanded,
|
|
551
|
+
enabled: active && !expanded,
|
|
539
552
|
},
|
|
540
553
|
{
|
|
541
554
|
id: 'drawer-detail',
|
|
@@ -544,9 +557,9 @@ export function WorkloadView({
|
|
|
544
557
|
category: 'Drawer' as const,
|
|
545
558
|
scope: 'drawer' as const,
|
|
546
559
|
handler: () => switchView(false),
|
|
547
|
-
enabled: !expanded,
|
|
560
|
+
enabled: active && !expanded,
|
|
548
561
|
},
|
|
549
|
-
], [expanded, onBack, onClose, switchView]))
|
|
562
|
+
], [active, expanded, onBack, onClose, switchView]))
|
|
550
563
|
|
|
551
564
|
const status = getResourceStatus(apiKind, resource)
|
|
552
565
|
|
|
@@ -586,7 +599,12 @@ export function WorkloadView({
|
|
|
586
599
|
<div className="flex items-center gap-1">
|
|
587
600
|
{onExpand && (
|
|
588
601
|
<button
|
|
589
|
-
onClick={onExpand}
|
|
602
|
+
onClick={() => onExpand({ yaml: showYaml })}
|
|
603
|
+
// Pre-mount the fullscreen view on hover/press so the click starts
|
|
604
|
+
// the morph instantly (its heavy mount is already paid for).
|
|
605
|
+
onPointerEnter={onExpandIntent}
|
|
606
|
+
onPointerDown={onExpandIntent}
|
|
607
|
+
onPointerLeave={onCancelExpandIntent}
|
|
590
608
|
className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
591
609
|
title="Open full view"
|
|
592
610
|
>
|
package/src/hooks/index.ts
CHANGED
|
@@ -46,6 +46,10 @@ interface RegisteredShortcut extends KeyboardShortcut {
|
|
|
46
46
|
interface KeyboardShortcutContextType {
|
|
47
47
|
registerShortcut: (shortcut: KeyboardShortcut) => () => void
|
|
48
48
|
activeShortcuts: KeyboardShortcut[]
|
|
49
|
+
/** When true, view-level shortcuts (the per-page scopes) are suppressed — used
|
|
50
|
+
* while a fullscreen detail overlay covers the page so the hidden view's keys
|
|
51
|
+
* don't fire. `global` (⌘K, the overlay's Escape) and `drawer` still fire. */
|
|
52
|
+
setBaseShortcutsSuppressed: (suppressed: boolean) => void
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
const KeyboardShortcutContext = createContext<KeyboardShortcutContextType | null>(null)
|
|
@@ -142,6 +146,9 @@ export function KeyboardShortcutProvider({ children }: { children: ReactNode })
|
|
|
142
146
|
const [version, setVersion] = useState(0)
|
|
143
147
|
const sequenceKeyRef = useRef<string | null>(null)
|
|
144
148
|
const sequenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
149
|
+
// Read in the keydown handler (a ref, so no re-subscribe needed on change).
|
|
150
|
+
const baseSuppressedRef = useRef(false)
|
|
151
|
+
const setBaseShortcutsSuppressed = useCallback((s: boolean) => { baseSuppressedRef.current = s }, [])
|
|
145
152
|
|
|
146
153
|
const registerShortcut = useCallback((shortcut: KeyboardShortcut) => {
|
|
147
154
|
const id = nextRegistrationId++
|
|
@@ -161,9 +168,13 @@ export function KeyboardShortcutProvider({ children }: { children: ReactNode })
|
|
|
161
168
|
const suppression = getSuppressionLevel(e)
|
|
162
169
|
const suppressed = suppression !== 'none'
|
|
163
170
|
|
|
164
|
-
// Get all enabled shortcuts, sorted by scope priority (highest first)
|
|
171
|
+
// Get all enabled shortcuts, sorted by scope priority (highest first).
|
|
172
|
+
// While a fullscreen overlay covers the page, drop the view-level scopes
|
|
173
|
+
// (the hidden page's keys) but keep `global` (⌘K, the overlay's Escape) and
|
|
174
|
+
// `drawer` so the overlay still works.
|
|
165
175
|
const shortcuts = Array.from(shortcutsRef.current.values())
|
|
166
176
|
.filter(s => s.enabled !== false)
|
|
177
|
+
.filter(s => !baseSuppressedRef.current || s.scope === 'global' || s.scope === 'drawer')
|
|
167
178
|
.sort((a, b) => SCOPE_PRIORITY[b.scope] - SCOPE_PRIORITY[a.scope])
|
|
168
179
|
|
|
169
180
|
// Check for multi-key sequence completion
|
|
@@ -258,12 +269,22 @@ export function KeyboardShortcutProvider({ children }: { children: ReactNode })
|
|
|
258
269
|
, [version])
|
|
259
270
|
|
|
260
271
|
return (
|
|
261
|
-
<KeyboardShortcutContext.Provider value={{ registerShortcut, activeShortcuts }}>
|
|
272
|
+
<KeyboardShortcutContext.Provider value={{ registerShortcut, activeShortcuts, setBaseShortcutsSuppressed }}>
|
|
262
273
|
{children}
|
|
263
274
|
</KeyboardShortcutContext.Provider>
|
|
264
275
|
)
|
|
265
276
|
}
|
|
266
277
|
|
|
278
|
+
/** Suppress view-level shortcuts while `active` (a fullscreen detail overlay is up).
|
|
279
|
+
* `global` and `drawer` scopes keep firing so ⌘K and the overlay's own keys work. */
|
|
280
|
+
export function useSuppressBaseShortcuts(active: boolean) {
|
|
281
|
+
const ctx = useContext(KeyboardShortcutContext)
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
ctx?.setBaseShortcutsSuppressed(active)
|
|
284
|
+
return () => ctx?.setBaseShortcutsSuppressed(false)
|
|
285
|
+
}, [ctx, active])
|
|
286
|
+
}
|
|
287
|
+
|
|
267
288
|
/** Register a keyboard shortcut. Automatically deregisters on unmount or when key config changes. */
|
|
268
289
|
export function useRegisterShortcut(shortcut: KeyboardShortcut) {
|
|
269
290
|
const ctx = useContext(KeyboardShortcutContext)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useRef } from 'react'
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
2
|
|
|
3
3
|
const MIN_SPIN_DURATION = 400 // ms
|
|
4
4
|
const SUCCESS_DISPLAY_DURATION = 1200 // ms
|
|
@@ -15,6 +15,18 @@ type RefreshPhase = 'idle' | 'spinning' | 'success'
|
|
|
15
15
|
export function useRefreshAnimation(refetchFn: () => void | Promise<unknown>): [() => void, boolean, RefreshPhase] {
|
|
16
16
|
const [phase, setPhase] = useState<RefreshPhase>('idle')
|
|
17
17
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
18
|
+
// Guards the async `finally`/timer callbacks from setting state after the
|
|
19
|
+
// host unmounts — this hook now lives on routed views that mount/unmount.
|
|
20
|
+
const mountedRef = useRef(true)
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
// Re-arm on (re)mount — Strict Mode's mount→cleanup→remount would otherwise
|
|
23
|
+
// leave this stuck false and freeze the spinner on the next refresh.
|
|
24
|
+
mountedRef.current = true
|
|
25
|
+
return () => {
|
|
26
|
+
mountedRef.current = false
|
|
27
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
28
|
+
}
|
|
29
|
+
}, [])
|
|
18
30
|
|
|
19
31
|
const wrappedRefetch = useCallback(() => {
|
|
20
32
|
if (timeoutRef.current) {
|
|
@@ -31,9 +43,10 @@ export function useRefreshAnimation(refetchFn: () => void | Promise<unknown>): [
|
|
|
31
43
|
const remaining = MIN_SPIN_DURATION - elapsed
|
|
32
44
|
|
|
33
45
|
const transitionToSuccess = () => {
|
|
46
|
+
if (!mountedRef.current) return
|
|
34
47
|
setPhase('success')
|
|
35
48
|
timeoutRef.current = setTimeout(() => {
|
|
36
|
-
setPhase('idle')
|
|
49
|
+
if (mountedRef.current) setPhase('idle')
|
|
37
50
|
}, SUCCESS_DISPLAY_DURATION)
|
|
38
51
|
}
|
|
39
52
|
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,14 @@ export * from './components/issues'
|
|
|
53
53
|
// Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
|
|
54
54
|
export * from './components/cluster-switcher'
|
|
55
55
|
|
|
56
|
+
// Namespace picker (shared scope-filter trigger+dropdown for OSS Radar and
|
|
57
|
+
// Radar Hub — pure presentation, data injected via props)
|
|
58
|
+
export * from './components/namespace-switcher'
|
|
59
|
+
|
|
60
|
+
// Scope pill — the shared bordered shell that groups the cluster + namespace
|
|
61
|
+
// segments into one unit (OSS header + Radar Hub cluster top bar)
|
|
62
|
+
export * from './components/scope-pill'
|
|
63
|
+
|
|
56
64
|
// Applications (shared host-agnostic list + detail shell for the deployable-
|
|
57
65
|
// software surface; OSS renders single-cluster, Cloud adds the fleet layer)
|
|
58
66
|
export * from './components/applications'
|
package/src/types/core.ts
CHANGED
|
@@ -587,6 +587,7 @@ export interface HelmOperation {
|
|
|
587
587
|
source: HelmOperationSource
|
|
588
588
|
confidence: HelmOperationConfidence
|
|
589
589
|
message: string
|
|
590
|
+
rawMessage?: string
|
|
590
591
|
evidence?: string
|
|
591
592
|
failureDescription?: string
|
|
592
593
|
revision?: number
|
|
@@ -597,6 +598,22 @@ export interface HelmOperation {
|
|
|
597
598
|
updated?: string
|
|
598
599
|
}
|
|
599
600
|
|
|
601
|
+
export type HelmOperationInsightState = 'active' | 'recovered'
|
|
602
|
+
|
|
603
|
+
export interface HelmSuggestedCompare {
|
|
604
|
+
revision1: number
|
|
605
|
+
revision2: number
|
|
606
|
+
reason?: string
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export interface HelmOperationInsight {
|
|
610
|
+
state: HelmOperationInsightState
|
|
611
|
+
primaryResource?: HelmOwnedResource
|
|
612
|
+
relatedResources?: HelmOwnedResource[]
|
|
613
|
+
signalCount?: number
|
|
614
|
+
suggestedCompare?: HelmSuggestedCompare
|
|
615
|
+
}
|
|
616
|
+
|
|
600
617
|
export interface HelmReleaseDetail {
|
|
601
618
|
name: string
|
|
602
619
|
namespace: string
|
|
@@ -621,6 +638,7 @@ export interface HelmReleaseDetail {
|
|
|
621
638
|
dependencies?: ChartDependency[]
|
|
622
639
|
lastOperation?: HelmOperation
|
|
623
640
|
operations?: HelmOperation[]
|
|
641
|
+
operationInsight?: HelmOperationInsight
|
|
624
642
|
// When set, this release was installed by Flux's helm-controller — see
|
|
625
643
|
// HelmRelease.managedByFluxHelmRelease for context. Format: "namespace/name".
|
|
626
644
|
managedByFluxHelmRelease?: string
|
|
@@ -631,6 +649,7 @@ export interface HelmHook {
|
|
|
631
649
|
namespace?: string
|
|
632
650
|
kind: string
|
|
633
651
|
path?: string
|
|
652
|
+
manifestChanged?: boolean
|
|
634
653
|
events: string[]
|
|
635
654
|
weight: number
|
|
636
655
|
status?: string
|
|
@@ -746,6 +765,15 @@ export interface NotesDiff {
|
|
|
746
765
|
diff: string
|
|
747
766
|
}
|
|
748
767
|
|
|
768
|
+
export interface HooksDiff {
|
|
769
|
+
revision1: number
|
|
770
|
+
revision2: number
|
|
771
|
+
added: HelmHook[]
|
|
772
|
+
removed: HelmHook[]
|
|
773
|
+
modified: HelmHook[]
|
|
774
|
+
unchanged: HelmHook[]
|
|
775
|
+
}
|
|
776
|
+
|
|
749
777
|
export interface HelmResourceRef {
|
|
750
778
|
kind: string
|
|
751
779
|
apiVersion?: string
|
|
@@ -753,12 +781,26 @@ export interface HelmResourceRef {
|
|
|
753
781
|
namespace: string
|
|
754
782
|
}
|
|
755
783
|
|
|
784
|
+
export interface HelmResourceFieldChange {
|
|
785
|
+
path: string
|
|
786
|
+
oldValue: unknown
|
|
787
|
+
newValue: unknown
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export interface HelmResourceChange extends HelmResourceRef {
|
|
791
|
+
summary?: string
|
|
792
|
+
fieldCount: number
|
|
793
|
+
fields: HelmResourceFieldChange[]
|
|
794
|
+
}
|
|
795
|
+
|
|
756
796
|
export interface ResourceDiff {
|
|
757
797
|
revision1: number
|
|
758
798
|
revision2: number
|
|
759
799
|
added: HelmResourceRef[]
|
|
760
800
|
removed: HelmResourceRef[]
|
|
801
|
+
modified: HelmResourceChange[]
|
|
761
802
|
unchanged: HelmResourceRef[]
|
|
803
|
+
parseErrorCount?: number
|
|
762
804
|
}
|
|
763
805
|
|
|
764
806
|
// Selected Helm release (for drawer state)
|
|
@@ -45,6 +45,7 @@ export interface GitOpsInsightSummary {
|
|
|
45
45
|
// Latest operation status message — surfaced inline in the status strip
|
|
46
46
|
// when an operation is in flight or just failed.
|
|
47
47
|
operationMessage?: string
|
|
48
|
+
rawOperationMessage?: string
|
|
48
49
|
source?: string
|
|
49
50
|
targetRevision?: string
|
|
50
51
|
lastRevision?: string
|
|
@@ -79,6 +80,7 @@ export interface GitOpsIssue {
|
|
|
79
80
|
scope: GitOpsScope
|
|
80
81
|
reason: string
|
|
81
82
|
message: string
|
|
83
|
+
rawMessage?: string
|
|
82
84
|
refs?: GitOpsInsightRef[]
|
|
83
85
|
action?: string
|
|
84
86
|
// Plain-English root cause when the message matched a recognized error
|
|
@@ -112,6 +114,7 @@ export interface GitOpsChange {
|
|
|
112
114
|
// Per-resource sync failure message (Argo's status.resources[].syncResult).
|
|
113
115
|
// Distinct from `message` (live health). Empty when sync succeeded.
|
|
114
116
|
syncError?: string
|
|
117
|
+
rawSyncError?: string
|
|
115
118
|
// Sync hook phase: PreSync / PostSync / SyncFail / PostDelete. Empty
|
|
116
119
|
// for non-hook resources.
|
|
117
120
|
hookPhase?: string
|
|
@@ -175,6 +178,7 @@ export interface GitOpsHistoryItem {
|
|
|
175
178
|
deployedAt?: string
|
|
176
179
|
phase?: string
|
|
177
180
|
message?: string
|
|
181
|
+
rawMessage?: string
|
|
178
182
|
source?: string
|
|
179
183
|
initiatedBy?: string
|
|
180
184
|
}
|
package/src/utils/animation.ts
CHANGED
|
@@ -22,6 +22,16 @@ export const DURATION_DOCK = 150
|
|
|
22
22
|
/** Toast exit animation */
|
|
23
23
|
export const DURATION_TOAST_EXIT = 200
|
|
24
24
|
|
|
25
|
+
/** Drawer ↔ fullscreen expand/collapse morph. Longer than DURATION_NORMAL so a
|
|
26
|
+
* large width change advances in small per-frame steps (reads as fluid, not
|
|
27
|
+
* abrupt). Drives the frame width, the content crossfade, and the JS window
|
|
28
|
+
* together — keep them in lockstep. */
|
|
29
|
+
export const DURATION_DRAWER_MORPH = 260
|
|
30
|
+
/** Decelerate curve — fast start, gentle settle. Reads as snappy/responsive (the
|
|
31
|
+
* earlier ease-in-out's slow start felt sluggish). Safe now that the pinned-layer
|
|
32
|
+
* crossfade fixed the reflow that originally needed the gentle start. */
|
|
33
|
+
export const EASE_DRAWER_MORPH = 'cubic-bezier(0.2, 0.8, 0.2, 1)'
|
|
34
|
+
|
|
25
35
|
// -- Tailwind class presets ---------------------------------------------------
|
|
26
36
|
// Reusable class fragments — import and spread into clsx() calls.
|
|
27
37
|
|