@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.
- package/package.json +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +6 -1
- package/src/components/audit/AuditAlerts.tsx +4 -0
- package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
- package/src/components/audit/AuditFindingsTable.tsx +4 -0
- package/src/components/audit/index.ts +1 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
- package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
- package/src/components/gitops/GitOpsTableView.tsx +49 -46
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +49 -40
- package/src/components/issues/ResourceIssuesSection.tsx +145 -0
- package/src/components/issues/diagnostic.ts +86 -0
- package/src/components/issues/index.ts +2 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/severity.ts +10 -9
- package/src/components/issues/types.ts +23 -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 +58 -83
- package/src/components/resources/cron-to-human.test.ts +41 -0
- package/src/components/resources/get-pod-problems.test.ts +18 -0
- package/src/components/resources/health-golden.test.ts +66 -0
- package/src/components/resources/renderers/JobRenderer.tsx +6 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
- package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
- package/src/components/resources/renderers/PodRenderer.tsx +28 -9
- package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
- package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
- package/src/components/resources/resource-utils-argo.test.ts +23 -0
- package/src/components/resources/resource-utils-argo.ts +5 -1
- package/src/components/resources/resource-utils-keda.ts +12 -8
- package/src/components/resources/resource-utils.ts +34 -14
- 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/timeline/TimelineSwimlanes.tsx +1 -0
- package/src/components/timeline/shared.tsx +15 -4
- package/src/components/topology/K8sResourceNode.tsx +28 -1
- package/src/components/topology/TopologyControls.tsx +90 -14
- package/src/components/topology/layout.ts +11 -5
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/PaneLoader.tsx +24 -6
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.test.tsx +35 -0
- package/src/components/ui/drawer-components.tsx +13 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +61 -13
- 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 +142 -3
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -0
- package/src/utils/applications.test.ts +55 -1
- package/src/utils/applications.ts +28 -7
- package/src/utils/badge-colors.ts +7 -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,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 {
|
|
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
|
)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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-
|
|
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}
|
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
|
|