@skyhook-io/k8s-ui 1.14.12 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/compare/CompareResourcePicker.tsx +4 -3
- package/src/components/gitops/RollbackDialog.tsx +4 -3
- package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
- package/src/components/gitops/health-provenance.test.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
- package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
- package/src/components/resources/KueueAdmissionSection.tsx +140 -51
- package/src/components/resources/ResourcesView.tsx +4 -0
- package/src/components/resources/printer-columns.test.ts +1 -1
- package/src/components/resources/renderers/JobRenderer.tsx +5 -1
- package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
- package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils-kueue.ts +14 -0
- package/src/components/resources/resource-utils-ray.ts +14 -4
- package/src/components/shared/CreateResourceDialog.tsx +6 -2
- package/src/components/shared/ResourceActionsBar.tsx +4 -2
- package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
- package/src/components/shared/SetImageDialog.tsx +4 -2
- package/src/components/shared/revision-role-badges.test.ts +1 -1
- package/src/components/trace/reachGraphModel.test.ts +1 -0
- package/src/components/trace/reachInspector.test.ts +4 -9
- package/src/components/ui/ConfirmDialog.tsx +5 -3
- package/src/components/ui/DialogPortal.test.tsx +279 -0
- package/src/components/ui/DialogPortal.tsx +147 -9
- package/src/components/ui/YamlReview.tsx +4 -1
- package/src/components/ui/drawer-components.test.tsx +14 -1
- package/src/components/ui/drawer-components.tsx +50 -40
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
type FocusEvent as ReactFocusEvent,
|
|
5
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from 'react'
|
|
2
8
|
import { createPortal } from 'react-dom'
|
|
3
9
|
import { clsx } from 'clsx'
|
|
4
10
|
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
@@ -12,6 +18,36 @@ interface DialogPortalProps {
|
|
|
12
18
|
className?: string
|
|
13
19
|
/** Prevent closing via Escape / backdrop click (e.g. during async operation) */
|
|
14
20
|
closable?: boolean
|
|
21
|
+
/** Id of the element that names the dialog (usually its title). Preferred over `ariaLabel`. */
|
|
22
|
+
ariaLabelledBy?: string
|
|
23
|
+
/** Accessible name when there is no visible title to reference. */
|
|
24
|
+
ariaLabel?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FOCUS_GUARD = 'fixed top-0 left-0 w-px h-0 overflow-hidden outline-none'
|
|
28
|
+
|
|
29
|
+
const TABBABLE_SELECTOR = [
|
|
30
|
+
'a[href]',
|
|
31
|
+
'area[href]',
|
|
32
|
+
'button:not([disabled])',
|
|
33
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
34
|
+
'select:not([disabled])',
|
|
35
|
+
'textarea:not([disabled])',
|
|
36
|
+
'iframe',
|
|
37
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
38
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
39
|
+
].join(',')
|
|
40
|
+
|
|
41
|
+
// A CSS-hidden element (display:none, visibility:hidden) can't take focus, so
|
|
42
|
+
// treating it as an edge would let Tab slip past the real last element.
|
|
43
|
+
function isVisible(el: HTMLElement): boolean {
|
|
44
|
+
return typeof el.checkVisibility === 'function' ? el.checkVisibility({ visibilityProperty: true }) : true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tabbableElements(panel: HTMLElement): HTMLElement[] {
|
|
48
|
+
return Array.from(panel.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter(
|
|
49
|
+
el => el.tabIndex >= 0 && !el.matches(':disabled') && !el.closest('[inert],[hidden]') && isVisible(el),
|
|
50
|
+
)
|
|
15
51
|
}
|
|
16
52
|
|
|
17
53
|
/**
|
|
@@ -19,14 +55,30 @@ interface DialogPortalProps {
|
|
|
19
55
|
* Renders children inside a centered panel portaled to document.body, so it works
|
|
20
56
|
* correctly even inside CSS-transformed containers (drawers, slide panels).
|
|
21
57
|
*
|
|
58
|
+
* Focus moves into the panel on open (unless a child already took it, e.g. via
|
|
59
|
+
* `autoFocus`), Tab wraps at the panel's edges, and focus returns to the element
|
|
60
|
+
* that opened the dialog at logical close.
|
|
61
|
+
*
|
|
22
62
|
* Usage:
|
|
23
|
-
* <DialogPortal open={showDialog} onClose={() => setShowDialog(false)} className="w-80">
|
|
24
|
-
* <h3>Title</h3>
|
|
63
|
+
* <DialogPortal open={showDialog} onClose={() => setShowDialog(false)} className="w-80" ariaLabelledBy={titleId}>
|
|
64
|
+
* <h3 id={titleId}>Title</h3>
|
|
25
65
|
* <p>Content</p>
|
|
26
66
|
* </DialogPortal>
|
|
27
67
|
*/
|
|
28
|
-
export function DialogPortal({
|
|
68
|
+
export function DialogPortal({
|
|
69
|
+
open,
|
|
70
|
+
onClose,
|
|
71
|
+
children,
|
|
72
|
+
className,
|
|
73
|
+
closable = true,
|
|
74
|
+
ariaLabelledBy,
|
|
75
|
+
ariaLabel,
|
|
76
|
+
}: DialogPortalProps) {
|
|
29
77
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
78
|
+
// `el` is null when focus entered from nowhere restorable (body, or an
|
|
79
|
+
// element outside the panel that has since been removed).
|
|
80
|
+
const openerRef = useRef<{ el: HTMLElement | null } | null>(null)
|
|
81
|
+
const pendingRestoreRef = useRef(false)
|
|
30
82
|
// The unmount window is the exit duration: shorter than the entrance, and
|
|
31
83
|
// exactly what the panel transition below runs — a mismatch here cut the
|
|
32
84
|
// close at two-thirds (200ms window under a 300ms panel).
|
|
@@ -44,15 +96,69 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
|
|
|
44
96
|
}
|
|
45
97
|
}
|
|
46
98
|
|
|
99
|
+
// Guards on either side of the panel catch focus leaving it, so inside the
|
|
100
|
+
// panel the browser's own Tab order applies — scroll containers, radio
|
|
101
|
+
// groups, editors that claim Tab — and only the wrap is decided here.
|
|
102
|
+
const focusEdge = (toLast: boolean) => {
|
|
103
|
+
const panel = dialogRef.current
|
|
104
|
+
if (!panel) return
|
|
105
|
+
// A candidate can still refuse focus; stopping on the first refusal would
|
|
106
|
+
// leave focus on the guard, one keypress from the page behind.
|
|
107
|
+
const items = tabbableElements(panel)
|
|
108
|
+
for (const el of toLast ? items.reverse() : items) {
|
|
109
|
+
el.focus()
|
|
110
|
+
if (document.activeElement === el) return
|
|
111
|
+
}
|
|
112
|
+
panel.focus()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Leaving past one edge wraps to the other; arriving from outside the panel
|
|
116
|
+
// enters at the near edge.
|
|
117
|
+
const handleGuardFocus = (edge: 'start' | 'end') => (e: ReactFocusEvent<HTMLSpanElement>) => {
|
|
118
|
+
const panel = dialogRef.current
|
|
119
|
+
const fromInside = e.relatedTarget instanceof Node && !!panel?.contains(e.relatedTarget)
|
|
120
|
+
focusEdge(edge === 'start' ? fromInside : !fromInside)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Focus drops to body when the focused control is disabled or removed (a
|
|
124
|
+
// confirm button entering its loading state); the next Tab would then land
|
|
125
|
+
// behind the modal. Focus elsewhere (a menu portaled out of the panel) is
|
|
126
|
+
// left alone.
|
|
127
|
+
const recoverStrayTab = (e: KeyboardEvent) => {
|
|
128
|
+
const panel = dialogRef.current
|
|
129
|
+
const active = document.activeElement
|
|
130
|
+
if (!panel || (active && active !== document.body)) return
|
|
131
|
+
e.preventDefault()
|
|
132
|
+
focusEdge(e.shiftKey)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// relatedTarget on the first focus into the panel is the opener. Reading
|
|
136
|
+
// document.activeElement in an effect would miss it when a child `autoFocus`
|
|
137
|
+
// has already moved focus during commit — which also happens before
|
|
138
|
+
// dialogRef is attached, hence currentTarget.
|
|
139
|
+
const handlePanelFocus = (e: ReactFocusEvent<HTMLDivElement>) => {
|
|
140
|
+
const panel = e.currentTarget
|
|
141
|
+
if (!open || openerRef.current || !panel.contains(e.target as Node)) return
|
|
142
|
+
const from = e.relatedTarget
|
|
143
|
+
openerRef.current = { el: from instanceof HTMLElement && !panel.contains(from) ? from : null }
|
|
144
|
+
}
|
|
145
|
+
|
|
47
146
|
useEffect(() => {
|
|
48
147
|
if (!open) return
|
|
49
148
|
|
|
50
149
|
const handleDocumentKeyDown = (e: KeyboardEvent) => {
|
|
51
|
-
if (e.key !== 'Escape') return
|
|
150
|
+
if (e.key !== 'Escape' && e.key !== 'Tab') return
|
|
151
|
+
// A dialog still animating out is inert and no longer the one on top.
|
|
52
152
|
const modalDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'))
|
|
153
|
+
.filter(d => !d.closest('[inert]'))
|
|
53
154
|
const topDialog = modalDialogs[modalDialogs.length - 1]
|
|
54
155
|
if (topDialog !== dialogRef.current) return
|
|
55
156
|
|
|
157
|
+
if (e.key === 'Tab') {
|
|
158
|
+
recoverStrayTab(e)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
56
162
|
if (dialogRef.current?.contains(e.target as Node)) return
|
|
57
163
|
|
|
58
164
|
e.stopPropagation()
|
|
@@ -67,17 +173,44 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
|
|
|
67
173
|
return () => document.removeEventListener('keydown', handleDocumentKeyDown, true)
|
|
68
174
|
}, [open, closable, onClose])
|
|
69
175
|
|
|
70
|
-
//
|
|
176
|
+
// The panel mounts a render after `open` flips (useAnimatedUnmount sets
|
|
177
|
+
// shouldRender in an effect), so this must also wait on shouldRender.
|
|
71
178
|
useEffect(() => {
|
|
72
|
-
|
|
73
|
-
|
|
179
|
+
const panel = dialogRef.current
|
|
180
|
+
if (!open || !shouldRender || !panel) return
|
|
181
|
+
if (!panel.contains(document.activeElement)) panel.focus()
|
|
182
|
+
}, [open, shouldRender])
|
|
183
|
+
|
|
184
|
+
// Restore at logical close (open → false, or unmount while open), not after
|
|
185
|
+
// the exit. The microtask lets StrictMode's simulated unmount/remount cancel
|
|
186
|
+
// the restore; otherwise dev builds would pull focus out of an autoFocus child.
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!open) return
|
|
189
|
+
pendingRestoreRef.current = false
|
|
190
|
+
return () => {
|
|
191
|
+
pendingRestoreRef.current = true
|
|
192
|
+
queueMicrotask(() => {
|
|
193
|
+
if (!pendingRestoreRef.current) return
|
|
194
|
+
pendingRestoreRef.current = false
|
|
195
|
+
const opener = openerRef.current?.el
|
|
196
|
+
openerRef.current = null
|
|
197
|
+
if (!opener?.isConnected) return
|
|
198
|
+
const active = document.activeElement
|
|
199
|
+
const panel = dialogRef.current
|
|
200
|
+
// Don't steal focus from wherever the close action deliberately sent it.
|
|
201
|
+
const focusStillOurs = !active || active === document.body || !!panel?.contains(active)
|
|
202
|
+
if (focusStillOurs) opener.focus({ preventScroll: true })
|
|
203
|
+
})
|
|
74
204
|
}
|
|
75
205
|
}, [open])
|
|
76
206
|
|
|
77
207
|
if (!shouldRender) return null
|
|
78
208
|
|
|
79
209
|
return createPortal(
|
|
80
|
-
<div
|
|
210
|
+
<div
|
|
211
|
+
className={clsx('fixed inset-0 z-50 flex items-center justify-center', !open && 'pointer-events-none')}
|
|
212
|
+
inert={!open}
|
|
213
|
+
>
|
|
81
214
|
<div
|
|
82
215
|
className={clsx(
|
|
83
216
|
'absolute inset-0 bg-black/60 backdrop-blur-sm',
|
|
@@ -87,12 +220,16 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
|
|
|
87
220
|
style={overlayTransitionStyle(isOpen, 'dialog')}
|
|
88
221
|
onClick={closable ? onClose : undefined}
|
|
89
222
|
/>
|
|
223
|
+
<span tabIndex={0} aria-hidden data-focus-guard onFocus={handleGuardFocus('start')} className={FOCUS_GUARD} />
|
|
90
224
|
<div
|
|
91
225
|
ref={dialogRef}
|
|
92
226
|
role="dialog"
|
|
93
227
|
aria-modal="true"
|
|
228
|
+
aria-labelledby={ariaLabelledBy}
|
|
229
|
+
aria-label={ariaLabelledBy ? undefined : ariaLabel}
|
|
94
230
|
tabIndex={-1}
|
|
95
231
|
onKeyDown={handleDialogKeyDown}
|
|
232
|
+
onFocus={handlePanelFocus}
|
|
96
233
|
className={clsx(
|
|
97
234
|
'relative bg-theme-surface border border-theme-border rounded-lg shadow-2xl mx-4 outline-none',
|
|
98
235
|
TRANSITION_PANEL,
|
|
@@ -103,6 +240,7 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
|
|
|
103
240
|
>
|
|
104
241
|
{children}
|
|
105
242
|
</div>
|
|
243
|
+
<span tabIndex={0} aria-hidden data-focus-guard onFocus={handleGuardFocus('end')} className={FOCUS_GUARD} />
|
|
106
244
|
</div>,
|
|
107
245
|
document.body,
|
|
108
246
|
)
|
|
@@ -34,6 +34,8 @@ export interface YamlReviewProps {
|
|
|
34
34
|
onClose?: () => void
|
|
35
35
|
onBack: () => void
|
|
36
36
|
onApply: () => void
|
|
37
|
+
/** Set when the review replaces a dialog's own title, which names the dialog. */
|
|
38
|
+
titleId?: string
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
function splitYaml(content: string) {
|
|
@@ -82,6 +84,7 @@ export function YamlReview({
|
|
|
82
84
|
onClose,
|
|
83
85
|
onBack,
|
|
84
86
|
onApply,
|
|
87
|
+
titleId,
|
|
85
88
|
}: YamlReviewProps) {
|
|
86
89
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
87
90
|
const reviewRef = useRef<HTMLDivElement>(null)
|
|
@@ -177,7 +180,7 @@ export function YamlReview({
|
|
|
177
180
|
</button>
|
|
178
181
|
<div className="h-4 w-px bg-theme-border" />
|
|
179
182
|
<div>
|
|
180
|
-
<div className="text-sm font-semibold text-theme-text-primary">
|
|
183
|
+
<div id={titleId} className="text-sm font-semibold text-theme-text-primary">
|
|
181
184
|
Review cluster-admitted changes
|
|
182
185
|
</div>
|
|
183
186
|
<div className="text-xs text-theme-text-tertiary">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
|
-
import { ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection } from './drawer-components'
|
|
3
|
+
import { EventsSection, ProblemAlerts, OperationalIssuesShownContext, RelatedResourcesSection } from './drawer-components'
|
|
4
4
|
|
|
5
5
|
const problems = [
|
|
6
6
|
{ color: 'red' as const, message: 'Application is Degraded' },
|
|
@@ -49,3 +49,16 @@ describe('RelatedResourcesSection', () => {
|
|
|
49
49
|
expect(html).toContain('<button')
|
|
50
50
|
})
|
|
51
51
|
})
|
|
52
|
+
|
|
53
|
+
describe('Recent Events layout', () => {
|
|
54
|
+
const events = Array.from({ length: 24 }, (_, i) => ({ id: String(i), source: 'k8s_event', eventType: 'Normal', reason: `Reason${i}`, timestamp: '2026-09-27T00:00:00Z' })) as any
|
|
55
|
+
it('keeps drawer scrolling but lets fullscreen events expand in page flow', () => {
|
|
56
|
+
const drawer = renderToString(<EventsSection events={events} />)
|
|
57
|
+
const fullscreen = renderToString(<EventsSection events={events} fullscreen />)
|
|
58
|
+
expect(drawer).toContain('max-h-64')
|
|
59
|
+
expect(drawer).not.toContain('Show 16 more events')
|
|
60
|
+
expect(fullscreen).not.toContain('max-h-64')
|
|
61
|
+
expect(fullscreen).toContain('Show 16 more events')
|
|
62
|
+
expect(fullscreen).toContain('Reason23')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Disclosure } from './Disclosure'
|
|
1
2
|
import { createContext, useContext, useState } from 'react'
|
|
2
3
|
import { Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
|
|
3
4
|
import { clsx } from 'clsx'
|
|
@@ -83,25 +84,26 @@ interface SectionProps {
|
|
|
83
84
|
children: React.ReactNode
|
|
84
85
|
defaultExpanded?: boolean
|
|
85
86
|
contentClassName?: string
|
|
87
|
+
variant?: 'plain' | 'card'
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
export function Section({ title, icon: Icon, children, defaultExpanded = true, contentClassName }: SectionProps) {
|
|
90
|
+
export function Section({ title, icon: Icon, children, defaultExpanded = true, contentClassName, variant = 'plain' }: SectionProps) {
|
|
89
91
|
const [expanded, setExpanded] = useState(defaultExpanded)
|
|
90
92
|
const { panelId, buttonProps } = useDisclosure(expanded)
|
|
91
93
|
|
|
92
94
|
return (
|
|
93
|
-
<div className=
|
|
95
|
+
<div className={variant === 'card' ? 'min-w-0 overflow-hidden rounded-lg border border-theme-border bg-theme-surface' : 'border-b-subtle pb-4 last:border-0'}>
|
|
94
96
|
<button
|
|
95
97
|
{...buttonProps}
|
|
96
98
|
onClick={() => setExpanded(!expanded)}
|
|
97
|
-
className=
|
|
99
|
+
className={clsx('flex items-center gap-2 w-full text-left hover:text-theme-text-primary transition-colors', variant === 'card' ? clsx('border-theme-border bg-theme-elevated px-3 py-2.5', expanded && 'border-b') : 'mb-2')}
|
|
98
100
|
>
|
|
99
101
|
<CollapseChevron open={expanded} className="w-4 h-4" />
|
|
100
102
|
{Icon && <Icon className="w-4 h-4 text-theme-text-secondary" />}
|
|
101
|
-
<span className="
|
|
103
|
+
<span className={clsx("font-medium text-theme-text-secondary", variant === 'card' ? 'text-xs uppercase tracking-wider' : 'text-sm')}>{title}</span>
|
|
102
104
|
</button>
|
|
103
105
|
<Collapse open={expanded} id={panelId}>
|
|
104
|
-
<div className={contentClassName ?? 'pl-6'}>{children}</div>
|
|
106
|
+
<div className={contentClassName ?? (variant === 'card' ? 'p-3' : 'pl-6')}>{children}</div>
|
|
105
107
|
</Collapse>
|
|
106
108
|
</div>
|
|
107
109
|
)
|
|
@@ -293,8 +295,10 @@ export function ConditionsSection({
|
|
|
293
295
|
conditions,
|
|
294
296
|
getConditionTone,
|
|
295
297
|
defaultExpanded,
|
|
298
|
+
variant,
|
|
296
299
|
}: {
|
|
297
300
|
conditions?: any[]
|
|
301
|
+
variant?: 'plain' | 'card'
|
|
298
302
|
defaultExpanded?: boolean
|
|
299
303
|
getConditionTone?: (condition: any) => ConditionTone | undefined
|
|
300
304
|
}) {
|
|
@@ -313,6 +317,7 @@ export function ConditionsSection({
|
|
|
313
317
|
|
|
314
318
|
return (
|
|
315
319
|
<Section
|
|
320
|
+
variant={variant}
|
|
316
321
|
title={`Conditions (${conditions.length})${failCount > 0 ? ` · ${failCount} failing` : ''}`}
|
|
317
322
|
defaultExpanded={defaultExpanded ?? conditions.length <= 6}
|
|
318
323
|
>
|
|
@@ -889,7 +894,6 @@ const RELATIONSHIP_TRUNCATE_LIMIT = 10
|
|
|
889
894
|
export function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroupProps) {
|
|
890
895
|
const [showAll, setShowAll] = useState(false)
|
|
891
896
|
if (!refs || refs.length === 0) return null
|
|
892
|
-
|
|
893
897
|
const truncated = !showAll && refs.length > RELATIONSHIP_TRUNCATE_LIMIT
|
|
894
898
|
const visibleRefs = truncated ? refs.slice(0, RELATIONSHIP_TRUNCATE_LIMIT) : refs
|
|
895
899
|
|
|
@@ -1027,10 +1031,12 @@ interface EventsSectionProps {
|
|
|
1027
1031
|
updatesError?: Error | null
|
|
1028
1032
|
/** Optional hint shown below the event list (e.g. "See Timeline tab for related resources") */
|
|
1029
1033
|
hint?: React.ReactNode
|
|
1034
|
+
fullscreen?: boolean
|
|
1030
1035
|
}
|
|
1031
1036
|
|
|
1032
|
-
export function EventsSection({ events, updates = [], isLoading, eventsError, updatesError, hint }: EventsSectionProps) {
|
|
1037
|
+
export function EventsSection({ events, updates = [], isLoading, eventsError, updatesError, hint, fullscreen = false }: EventsSectionProps) {
|
|
1033
1038
|
const [showUpdates, setShowUpdates] = useState(false)
|
|
1039
|
+
const [showAllEvents, setShowAllEvents] = useState(false)
|
|
1034
1040
|
|
|
1035
1041
|
if (isLoading) {
|
|
1036
1042
|
return (
|
|
@@ -1085,42 +1091,46 @@ export function EventsSection({ events, updates = [], isLoading, eventsError, up
|
|
|
1085
1091
|
)
|
|
1086
1092
|
}
|
|
1087
1093
|
|
|
1094
|
+
const renderEvent = (event: TimelineEvent, i: number) => (
|
|
1095
|
+
<div
|
|
1096
|
+
key={`${event.id}-${i}`}
|
|
1097
|
+
className={clsx(
|
|
1098
|
+
'p-2 rounded text-sm border-l-2',
|
|
1099
|
+
event.eventType === 'Warning' || (isChangeEvent(event) && event.eventType === 'delete')
|
|
1100
|
+
? 'bg-red-500/10 border-red-500'
|
|
1101
|
+
: isK8sEvent(event)
|
|
1102
|
+
? 'bg-blue-500/10 border-blue-500'
|
|
1103
|
+
: 'bg-theme-elevated/30 border-theme-border'
|
|
1104
|
+
)}
|
|
1105
|
+
>
|
|
1106
|
+
<div className="flex items-center justify-between gap-2">
|
|
1107
|
+
<span className="font-medium text-theme-text-primary">
|
|
1108
|
+
{isK8sEvent(event) ? event.reason : event.eventType}
|
|
1109
|
+
</span>
|
|
1110
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
1111
|
+
{formatEventTime(event.timestamp)}
|
|
1112
|
+
</span>
|
|
1113
|
+
</div>
|
|
1114
|
+
{event.message && (
|
|
1115
|
+
<div className="text-xs text-theme-text-secondary mt-1 line-clamp-2">
|
|
1116
|
+
{event.message}
|
|
1117
|
+
</div>
|
|
1118
|
+
)}
|
|
1119
|
+
{isChangeEvent(event) && event.diff?.summary && (
|
|
1120
|
+
<div className="text-xs text-theme-text-secondary mt-1">
|
|
1121
|
+
{event.diff.summary}
|
|
1122
|
+
</div>
|
|
1123
|
+
)}
|
|
1124
|
+
</div>
|
|
1125
|
+
)
|
|
1088
1126
|
return (
|
|
1089
1127
|
<Section title={`Recent Events (${visible.length})`} defaultExpanded>
|
|
1090
|
-
<div className=
|
|
1091
|
-
{visible.
|
|
1092
|
-
<div
|
|
1093
|
-
key={`${event.id}-${i}`}
|
|
1094
|
-
className={clsx(
|
|
1095
|
-
'p-2 rounded text-sm border-l-2',
|
|
1096
|
-
event.eventType === 'Warning' || (isChangeEvent(event) && event.eventType === 'delete')
|
|
1097
|
-
? 'bg-red-500/10 border-red-500'
|
|
1098
|
-
: isK8sEvent(event)
|
|
1099
|
-
? 'bg-blue-500/10 border-blue-500'
|
|
1100
|
-
: 'bg-theme-elevated/30 border-theme-border'
|
|
1101
|
-
)}
|
|
1102
|
-
>
|
|
1103
|
-
<div className="flex items-center justify-between gap-2">
|
|
1104
|
-
<span className="font-medium text-theme-text-primary">
|
|
1105
|
-
{isK8sEvent(event) ? event.reason : event.eventType}
|
|
1106
|
-
</span>
|
|
1107
|
-
<span className="text-xs text-theme-text-tertiary">
|
|
1108
|
-
{formatEventTime(event.timestamp)}
|
|
1109
|
-
</span>
|
|
1110
|
-
</div>
|
|
1111
|
-
{event.message && (
|
|
1112
|
-
<div className="text-xs text-theme-text-secondary mt-1 line-clamp-2">
|
|
1113
|
-
{event.message}
|
|
1114
|
-
</div>
|
|
1115
|
-
)}
|
|
1116
|
-
{isChangeEvent(event) && event.diff?.summary && (
|
|
1117
|
-
<div className="text-xs text-theme-text-secondary mt-1">
|
|
1118
|
-
{event.diff.summary}
|
|
1119
|
-
</div>
|
|
1120
|
-
)}
|
|
1121
|
-
</div>
|
|
1122
|
-
))}
|
|
1128
|
+
<div tabIndex={fullscreen ? undefined : 0} className={clsx('space-y-2', !fullscreen && 'max-h-64 overflow-y-auto')}>
|
|
1129
|
+
{(fullscreen ? visible.slice(0, 8) : visible).map(renderEvent)}
|
|
1123
1130
|
</div>
|
|
1131
|
+
{fullscreen && visible.length > 8 && <Disclosure open={showAllEvents} onOpenChange={setShowAllEvents} summary={showAllEvents ? 'Show fewer events' : `Show ${visible.length - 8} more events`} className="mt-2" summaryClassName="text-xs text-accent-text">
|
|
1132
|
+
<div className="mt-2 space-y-2">{visible.slice(8).map(renderEvent)}</div>
|
|
1133
|
+
</Disclosure>}
|
|
1124
1134
|
{errors}
|
|
1125
1135
|
{toggle && <div className="mt-2">{toggle}</div>}
|
|
1126
1136
|
{hint && <div className="mt-2">{hint}</div>}
|