@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.
Files changed (36) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +4 -1
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  5. package/src/components/gitops/GitOpsTableView.tsx +46 -45
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  7. package/src/components/issues/IssuesView.tsx +41 -5
  8. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  9. package/src/components/issues/diagnostic.ts +22 -0
  10. package/src/components/issues/index.ts +1 -1
  11. package/src/components/issues/issues.test.ts +21 -0
  12. package/src/components/issues/types.ts +18 -0
  13. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  14. package/src/components/namespace-switcher/index.ts +6 -0
  15. package/src/components/resources/ResourcesView.tsx +20 -81
  16. package/src/components/scope-pill/ScopePill.tsx +35 -0
  17. package/src/components/scope-pill/index.ts +2 -0
  18. package/src/components/timeline/TimelineList.tsx +27 -1
  19. package/src/components/topology/TopologyControls.tsx +90 -14
  20. package/src/components/ui/FreshnessControl.tsx +153 -0
  21. package/src/components/ui/SortableTh.tsx +16 -10
  22. package/src/components/ui/Toast.tsx +1 -1
  23. package/src/components/ui/index.ts +2 -0
  24. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  25. package/src/components/workload/WorkloadView.tsx +26 -8
  26. package/src/hooks/index.ts +1 -0
  27. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  28. package/src/hooks/useRefreshAnimation.ts +15 -2
  29. package/src/index.ts +8 -0
  30. package/src/types/core.ts +42 -0
  31. package/src/types/gitops-insights.ts +4 -0
  32. package/src/utils/animation.ts +10 -0
  33. package/src/utils/format-freshness.test.ts +34 -0
  34. package/src/utils/format.ts +32 -0
  35. package/src/utils/resource-hierarchy.test.ts +51 -0
  36. package/src/utils/resource-hierarchy.ts +7 -4
@@ -1,5 +1,8 @@
1
- import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
2
- import { TRANSITION_DRAWER } from '../../utils/animation'
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
- onExpand?: (resource: SelectedResource) => void
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
- export function ResourceDetailDrawer({ resource, onClose, onNavigate, initialTab, isOpen = true, expanded, onCollapse, onExpand, onNavigateToResource, headerHeight: headerHeightProp, leftOffset = 0, children }: ResourceDetailDrawerProps) {
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
- // Detect collapse direction: was expanded last render, now not.
62
- // Width snaps instantly on collapse (0ms) so content and size match.
63
- // Expand keeps the nice 300ms width animation via TRANSITION_DRAWER.
64
- const wasExpanded = useRef(!!expanded)
65
- const isCollapsing = wasExpanded.current && !expanded
66
- wasExpanded.current = !!expanded
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
- // Resize handlers (disabled when expanded)
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 shadow-drawer z-40',
121
- TRANSITION_DRAWER,
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: expanded ? `calc(100% - ${leftOffset}px)` : drawerWidth,
275
+ width: containerWidth,
129
276
  top: headerHeight,
130
277
  height: `calc(100% - ${headerHeight}px - ${dockInset}px)`,
131
- // Collapse is instant no animation, content and width snap together.
132
- // Expand + slide-in/out animate via TRANSITION_DRAWER class.
133
- ...(isCollapsing && { transition: 'none' }),
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
- {children({
149
- resource,
150
- expanded: !!expanded,
151
- initialTab,
152
- onClose,
153
- onExpand: onExpand ? () => onExpand(resource) : undefined,
154
- onBack: onCollapse ? () => onCollapse() : undefined,
155
- onNavigateToResource: handleNavigate,
156
- onCollapseToDrawer: onCollapse ? () => onCollapse() : undefined,
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
- onExpand?: () => void
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
- scope: expanded ? 'global' as const : 'drawer' as const,
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: true,
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
  >
@@ -5,5 +5,6 @@ export {
5
5
  useRegisterShortcut,
6
6
  useRegisterShortcuts,
7
7
  useActiveShortcuts,
8
+ useSuppressBaseShortcuts,
8
9
  } from './useKeyboardShortcuts'
9
10
  export type { KeyboardShortcut, ShortcutCategory } from './useKeyboardShortcuts'
@@ -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
  }
@@ -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