@skyhook-io/k8s-ui 1.8.6 → 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 (68) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +6 -1
  4. package/src/components/audit/AuditAlerts.tsx +4 -0
  5. package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
  6. package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
  7. package/src/components/audit/AuditFindingsTable.tsx +4 -0
  8. package/src/components/audit/index.ts +1 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  10. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
  11. package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
  12. package/src/components/gitops/GitOpsTableView.tsx +49 -46
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  14. package/src/components/issues/IssuesView.tsx +49 -40
  15. package/src/components/issues/ResourceIssuesSection.tsx +145 -0
  16. package/src/components/issues/diagnostic.ts +86 -0
  17. package/src/components/issues/index.ts +2 -1
  18. package/src/components/issues/issues.test.ts +21 -0
  19. package/src/components/issues/severity.ts +10 -9
  20. package/src/components/issues/types.ts +23 -0
  21. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  22. package/src/components/namespace-switcher/index.ts +6 -0
  23. package/src/components/resources/ResourcesView.tsx +58 -83
  24. package/src/components/resources/cron-to-human.test.ts +41 -0
  25. package/src/components/resources/get-pod-problems.test.ts +18 -0
  26. package/src/components/resources/health-golden.test.ts +66 -0
  27. package/src/components/resources/renderers/JobRenderer.tsx +6 -2
  28. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
  30. package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
  31. package/src/components/resources/renderers/PodRenderer.tsx +28 -9
  32. package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
  33. package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
  34. package/src/components/resources/resource-utils-argo.test.ts +23 -0
  35. package/src/components/resources/resource-utils-argo.ts +5 -1
  36. package/src/components/resources/resource-utils-keda.ts +12 -8
  37. package/src/components/resources/resource-utils.ts +34 -14
  38. package/src/components/scope-pill/ScopePill.tsx +35 -0
  39. package/src/components/scope-pill/index.ts +2 -0
  40. package/src/components/timeline/TimelineList.tsx +27 -1
  41. package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
  42. package/src/components/timeline/shared.tsx +15 -4
  43. package/src/components/topology/K8sResourceNode.tsx +28 -1
  44. package/src/components/topology/TopologyControls.tsx +90 -14
  45. package/src/components/topology/layout.ts +11 -5
  46. package/src/components/ui/FreshnessControl.tsx +153 -0
  47. package/src/components/ui/PaneLoader.tsx +24 -6
  48. package/src/components/ui/SortableTh.tsx +16 -10
  49. package/src/components/ui/Toast.tsx +1 -1
  50. package/src/components/ui/drawer-components.test.tsx +35 -0
  51. package/src/components/ui/drawer-components.tsx +13 -1
  52. package/src/components/ui/index.ts +2 -0
  53. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  54. package/src/components/workload/WorkloadView.tsx +61 -13
  55. package/src/hooks/index.ts +1 -0
  56. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  57. package/src/hooks/useRefreshAnimation.ts +15 -2
  58. package/src/index.ts +8 -0
  59. package/src/types/core.ts +142 -3
  60. package/src/types/gitops-insights.ts +4 -0
  61. package/src/utils/animation.ts +10 -0
  62. package/src/utils/applications.test.ts +55 -1
  63. package/src/utils/applications.ts +28 -7
  64. package/src/utils/badge-colors.ts +7 -0
  65. package/src/utils/format-freshness.test.ts +34 -0
  66. package/src/utils/format.ts +32 -0
  67. package/src/utils/resource-hierarchy.test.ts +51 -0
  68. package/src/utils/resource-hierarchy.ts +7 -4
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
1
+ import { useState, createContext, useContext } from 'react'
2
2
  import { ChevronRight, Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { formatAge, formatDuration, formatResources } from '../resources/resource-utils'
@@ -446,6 +446,18 @@ export interface Problem {
446
446
  }
447
447
 
448
448
  /** Displays a list of problem alerts (warnings and errors) */
449
+ // True when the resource detail is already rendering a dedicated, authoritative
450
+ // "Operational Issues" section (the Issues pipeline — richer cause/action). Only
451
+ // renderers whose problems the pipeline COMPREHENSIVELY covers read this and drop
452
+ // their own problem array so the same failure isn't shown twice — today that's
453
+ // PodRenderer and WorkloadRenderer (workload + pod runtime). It is deliberately
454
+ // NOT wired into ProblemAlerts: that component is used only by GitOps renderers
455
+ // (Argo/Flux), whose Degraded/OutOfSync/revision-mismatch banners the pipeline
456
+ // does not fully emit (e.g. OutOfSync only for automated Argo apps) — suppressing
457
+ // them would hide real problems, which is worse than an occasional duplicate.
458
+ export const OperationalIssuesShownContext = createContext(false)
459
+ export const useOperationalIssuesShown = () => useContext(OperationalIssuesShownContext)
460
+
449
461
  export function ProblemAlerts({ problems }: { problems: Problem[] }) {
450
462
  if (problems.length === 0) return null
451
463
 
@@ -1,4 +1,6 @@
1
1
  export { Tooltip } from './Tooltip'
2
+ export { FreshnessControl } from './FreshnessControl'
3
+ export type { FreshnessMode, FreshnessConnection } from './FreshnessControl'
2
4
  export { PaneLoader } from './PaneLoader'
3
5
  export { ClusterName } from './ClusterName'
4
6
  export { MiddleEllipsis } from './MiddleEllipsis'
@@ -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
  )
@@ -52,7 +52,7 @@ import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } f
52
52
  import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
53
53
  import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
54
54
  import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
55
- import { getKindColorOutline, displayKindName } from '../ui/drawer-components'
55
+ import { getKindColorOutline, displayKindName, OperationalIssuesShownContext } from '../ui/drawer-components'
56
56
  import { midTruncate } from '../../utils/format'
57
57
 
58
58
  export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'yaml'
@@ -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 */
@@ -211,6 +218,17 @@ interface WorkloadViewProps {
211
218
  isMetricsAvailable?: (kind: string, resource: any) => boolean
212
219
  /** Render extra content at the bottom of the overview tab (e.g. audit findings) */
213
220
  renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode
221
+ /** Render content at the TOP of the overview tab, above the renderer (e.g. live
222
+ * Operational Issues). Optional + additive — consumers that don't pass it are
223
+ * unaffected. Only rendered when `hasOperationalIssues` is true: the lead
224
+ * component returns null when empty, but its padded wrapper can't tell, so
225
+ * gating on the flag avoids an empty top gap on healthy resources. */
226
+ renderOverviewLead?: (props: { kind: string; namespace: string; name: string }) => ReactNode
227
+ /** When true, renderers suppress their own status-derived problem displays
228
+ * because a dedicated Operational Issues section is shown (the host fetched
229
+ * live issues for this resource). Avoids showing the same failure twice.
230
+ * Also gates the `renderOverviewLead` wrapper (see above). */
231
+ hasOperationalIssues?: boolean
214
232
 
215
233
  // ── Duplicate ────────────────────────────────────────────────────────────
216
234
  /** Duplicate handler — opens create dialog with this resource's YAML */
@@ -237,8 +255,11 @@ export function WorkloadView({
237
255
  onNavigateToResource,
238
256
  onCollapseToDrawer,
239
257
  expanded = true,
258
+ active = true,
240
259
  onClose,
241
260
  onExpand,
261
+ onExpandIntent,
262
+ onCancelExpandIntent,
242
263
  initialTab,
243
264
  group,
244
265
  breadcrumb,
@@ -282,6 +303,8 @@ export function WorkloadView({
282
303
  onDuplicate,
283
304
  onDownload,
284
305
  renderOverviewExtra,
306
+ renderOverviewLead,
307
+ hasOperationalIssues,
285
308
  // Actions bar
286
309
  actionsBarProps,
287
310
  // Renderer overrides
@@ -511,9 +534,12 @@ export function WorkloadView({
511
534
  keys: 'Escape',
512
535
  description: expanded ? 'Go back' : 'Close drawer',
513
536
  category: expanded ? 'Navigation' as const : 'Drawer' as const,
514
- 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,
515
541
  handler: expanded ? onBack : () => onClose?.(),
516
- enabled: true,
542
+ enabled: active,
517
543
  },
518
544
  {
519
545
  id: 'drawer-yaml',
@@ -522,7 +548,7 @@ export function WorkloadView({
522
548
  category: 'Drawer' as const,
523
549
  scope: 'drawer' as const,
524
550
  handler: () => switchView(true),
525
- enabled: !expanded,
551
+ enabled: active && !expanded,
526
552
  },
527
553
  {
528
554
  id: 'drawer-detail',
@@ -531,9 +557,9 @@ export function WorkloadView({
531
557
  category: 'Drawer' as const,
532
558
  scope: 'drawer' as const,
533
559
  handler: () => switchView(false),
534
- enabled: !expanded,
560
+ enabled: active && !expanded,
535
561
  },
536
- ], [expanded, onBack, onClose, switchView]))
562
+ ], [active, expanded, onBack, onClose, switchView]))
537
563
 
538
564
  const status = getResourceStatus(apiKind, resource)
539
565
 
@@ -573,7 +599,12 @@ export function WorkloadView({
573
599
  <div className="flex items-center gap-1">
574
600
  {onExpand && (
575
601
  <button
576
- 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}
577
608
  className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
578
609
  title="Open full view"
579
610
  >
@@ -636,7 +667,9 @@ export function WorkloadView({
636
667
  {/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
637
668
  <div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
638
669
  {!resource ? (
639
- <FetchResult loading={resourceLoading} error={resourceError} className="h-32" />
670
+ // Fill the drawer body so the loading logo centers in it, not in a
671
+ // 128px box pinned to the top (matches the splash/PaneLoader centering).
672
+ <FetchResult loading={resourceLoading} error={resourceError} className="h-full" />
640
673
  ) : showYaml ? (
641
674
  <EditableYamlView
642
675
  resource={selectedResource}
@@ -652,7 +685,12 @@ export function WorkloadView({
652
685
  onDownload={onDownload}
653
686
  />
654
687
  ) : (
655
- <>
688
+ <OperationalIssuesShownContext.Provider value={!!hasOperationalIssues}>
689
+ {renderOverviewLead && hasOperationalIssues && (
690
+ <div className="px-4 pt-4">
691
+ {renderOverviewLead({ kind, namespace, name })}
692
+ </div>
693
+ )}
656
694
  <ResourceRendererDispatch
657
695
  resource={selectedResource}
658
696
  data={resource}
@@ -679,7 +717,7 @@ export function WorkloadView({
679
717
  {renderOverviewExtra({ kind, namespace, name })}
680
718
  </div>
681
719
  )}
682
- </>
720
+ </OperationalIssuesShownContext.Provider>
683
721
  )}
684
722
  </div>
685
723
  </div>
@@ -688,6 +726,7 @@ export function WorkloadView({
688
726
 
689
727
  // ── Expanded (full) mode ─────────────────────────────────────────────────
690
728
  return (
729
+ <OperationalIssuesShownContext.Provider value={!!hasOperationalIssues}>
691
730
  <DetailShell
692
731
  breadcrumb={breadcrumb}
693
732
  nav={
@@ -802,6 +841,7 @@ export function WorkloadView({
802
841
  eventsError={overviewEventsError}
803
842
  updatesError={resourceFocusedUpdatesError}
804
843
  extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
844
+ leadContent={hasOperationalIssues && renderOverviewLead ? renderOverviewLead({ kind, namespace, name }) : undefined}
805
845
  />
806
846
  )}
807
847
  {effectiveTab === 'topology' && (
@@ -881,7 +921,7 @@ export function WorkloadView({
881
921
  {yamlObject && !yamlObject.primary && renderRelatedYaml ? (
882
922
  renderRelatedYaml(yamlObject)
883
923
  ) : !resource ? (
884
- <FetchResult loading={resourceLoading} error={resourceError} className="h-32" />
924
+ <FetchResult loading={resourceLoading} error={resourceError} className="h-full" />
885
925
  ) : (
886
926
  <EditableYamlView
887
927
  resource={selectedResource}
@@ -901,6 +941,7 @@ export function WorkloadView({
901
941
  </div>
902
942
  )}
903
943
  </DetailShell>
944
+ </OperationalIssuesShownContext.Provider>
904
945
  )
905
946
  }
906
947
 
@@ -1335,6 +1376,7 @@ function InfoTab({
1335
1376
  eventsError,
1336
1377
  updatesError,
1337
1378
  extraContent,
1379
+ leadContent,
1338
1380
  }: {
1339
1381
  resource: any
1340
1382
  selectedResource: SelectedResource
@@ -1358,6 +1400,7 @@ function InfoTab({
1358
1400
  eventsError?: Error | null
1359
1401
  updatesError?: Error | null
1360
1402
  extraContent?: ReactNode
1403
+ leadContent?: ReactNode
1361
1404
  }) {
1362
1405
  if (!resource) {
1363
1406
  return <FetchResult loading={isLoading} error={error} className="h-full" />
@@ -1365,6 +1408,11 @@ function InfoTab({
1365
1408
 
1366
1409
  return (
1367
1410
  <div className="h-full overflow-auto">
1411
+ {leadContent && (
1412
+ <div className="px-4 pt-4">
1413
+ {leadContent}
1414
+ </div>
1415
+ )}
1368
1416
  <ResourceRendererDispatch
1369
1417
  resource={selectedResource}
1370
1418
  data={resource}
@@ -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