@skyhook-io/k8s-ui 1.14.12 → 1.16.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 +3 -3
- package/src/components/audit/AuditFindingsTable.tsx +1 -1
- package/src/components/checks/ChecksView.tsx +8 -8
- package/src/components/compare/CompareResourcePicker.tsx +4 -3
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
- 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/index.ts +1 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +2 -2
- package/src/components/logs/LogCore.tsx +131 -97
- package/src/components/logs/StructuredLogLine.tsx +18 -28
- package/src/components/logs/log-palette.ts +4 -0
- package/src/components/logs/useLogBuffer.ts +8 -39
- package/src/components/logs/useLogSearch.ts +52 -24
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
- 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/PodRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/PodRenderer.tsx +10 -2
- package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
- package/src/components/resources/renderers/PolicySection.tsx +10 -1
- 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/DrainPlanDialog.test.tsx +10 -0
- package/src/components/shared/DrainPlanDialog.tsx +14 -3
- package/src/components/shared/ResourceActionsBar.tsx +18 -11
- 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/FilterPill.tsx +1 -1
- package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
- package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
- package/src/components/ui/RadarUpgradeNote.tsx +129 -0
- package/src/components/ui/ResourceBar.tsx +12 -6
- package/src/components/ui/SelectMenu.tsx +62 -19
- 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
- package/src/components/ui/index.ts +8 -0
- package/src/components/workload/WorkloadView.tsx +109 -3
- package/src/types/core.ts +24 -4
- package/src/types/fetch-error.ts +33 -0
- package/src/utils/log-format.ts +1 -1
- package/src/utils/log-level.test.ts +269 -0
- package/src/utils/log-level.ts +301 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +87 -52
|
@@ -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
|
)
|
|
@@ -52,7 +52,7 @@ const TONE_ACTIVE: Record<FilterPillTone, string> = {
|
|
|
52
52
|
high: 'bg-orange-500/15 border-orange-500/40 text-orange-800 dark:text-orange-300',
|
|
53
53
|
medium: 'bg-yellow-500/15 border-yellow-500/40 text-yellow-800 dark:text-yellow-300',
|
|
54
54
|
ok: 'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300',
|
|
55
|
-
brand: 'bg-[var(--color-brand-50)] border-[var(--color-radar-accent)] text-theme-text-primary dark:bg-[var(--color-brand-950)]',
|
|
55
|
+
brand: 'bg-[var(--color-brand-50,var(--accent-muted))] border-[var(--color-radar-accent,var(--accent))] text-theme-text-primary dark:bg-[var(--color-brand-950,var(--accent-muted))]',
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const INACTIVE = 'border-theme-border-light text-theme-text-secondary hover:border-theme-border hover:text-theme-text-primary hover:bg-theme-hover/50'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { expect, it, vi } from 'vitest'
|
|
5
|
+
import { RadarUpgradeContext, RadarUpgradeNote } from './RadarUpgradeNote'
|
|
6
|
+
|
|
7
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
8
|
+
|
|
9
|
+
it('hands the whole requirement to the host when the user asks to upgrade', async () => {
|
|
10
|
+
const onRequestUpgrade = vi.fn()
|
|
11
|
+
const requirement = { feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'v1.7.2', latestVersion: 'v1.16.0' }
|
|
12
|
+
const element = document.createElement('div')
|
|
13
|
+
const root = createRoot(element)
|
|
14
|
+
await act(async () => {
|
|
15
|
+
root.render(
|
|
16
|
+
<RadarUpgradeContext.Provider value={{ onRequestUpgrade }}>
|
|
17
|
+
<RadarUpgradeNote requirement={requirement} />
|
|
18
|
+
</RadarUpgradeContext.Provider>,
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
await act(async () => { element.querySelector('button')!.click() })
|
|
22
|
+
expect(onRequestUpgrade).toHaveBeenCalledWith(requirement)
|
|
23
|
+
await act(async () => root.unmount())
|
|
24
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { formatRadarVersion, radarUpgradeDetail, radarUpgradeHeadline, RadarUpgradeNote } from './RadarUpgradeNote'
|
|
4
|
+
import { getRadarUpgradeRequirement } from '../../types/fetch-error'
|
|
5
|
+
|
|
6
|
+
describe('radarUpgradeHeadline', () => {
|
|
7
|
+
it('says the cluster is behind, not the UI', () => {
|
|
8
|
+
expect(radarUpgradeHeadline('Capacity')).toBe('Capacity needs a newer Radar on this cluster')
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('radarUpgradeDetail', () => {
|
|
13
|
+
it('quotes the minimum and a real current version', () => {
|
|
14
|
+
expect(radarUpgradeDetail({ feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: '1.7.2' }))
|
|
15
|
+
.toBe('Available from Radar v1.10. This cluster runs v1.7.2.')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('leaves out a current version that is not a version', () => {
|
|
19
|
+
expect(radarUpgradeDetail({ feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'dev' }))
|
|
20
|
+
.toBe('Available from Radar v1.10.')
|
|
21
|
+
expect(radarUpgradeDetail({ feature: 'Policy results', minimumVersion: 'v1.10.0' }))
|
|
22
|
+
.toBe('Available from Radar v1.10.')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('radarUpgradeDetail without a known first release', () => {
|
|
27
|
+
const unknownSince = { feature: 'Drift alerts', currentVersion: 'v1.7.2' }
|
|
28
|
+
|
|
29
|
+
it('asks for the latest release when it is newer than the running one', () => {
|
|
30
|
+
expect(radarUpgradeDetail({ ...unknownSince, latestVersion: '1.16.0' }))
|
|
31
|
+
.toBe('Available in the latest Radar, v1.16. This cluster runs v1.7.2.')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('only names the running version when no target release is known', () => {
|
|
35
|
+
expect(radarUpgradeDetail(unknownSince)).toBe('This cluster runs v1.7.2.')
|
|
36
|
+
expect(radarUpgradeDetail({ ...unknownSince, currentVersion: 'v1.16.0', latestVersion: 'v1.16.0' }))
|
|
37
|
+
.toBe('This cluster runs v1.16.')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('reads the same way inline', () => {
|
|
41
|
+
const html = renderToString(<RadarUpgradeNote requirement={{ ...unknownSince, latestVersion: 'v1.16.0' }} />)
|
|
42
|
+
expect(html).toContain('need the latest Radar (v1.16) on this cluster. It runs v1.7.2.')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('formatRadarVersion', () => {
|
|
47
|
+
it('drops a zero patch and adds the v', () => {
|
|
48
|
+
expect(formatRadarVersion('v1.14.0')).toBe('v1.14')
|
|
49
|
+
expect(formatRadarVersion('1.7.2')).toBe('v1.7.2')
|
|
50
|
+
expect(formatRadarVersion('v1.7.2-dirty')).toBe('v1.7.2-dirty')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('getRadarUpgradeRequirement', () => {
|
|
55
|
+
it('reads the requirement off any error that carries one', () => {
|
|
56
|
+
const error = Object.assign(new Error('x'), { radarUpgrade: { feature: 'Drain plans', minimumVersion: 'v1.14.0' } })
|
|
57
|
+
expect(getRadarUpgradeRequirement(error)).toEqual({ feature: 'Drain plans', minimumVersion: 'v1.14.0' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('ignores plain errors and malformed requirements', () => {
|
|
61
|
+
expect(getRadarUpgradeRequirement(new Error('HTTP 404 (Not Found)'))).toBeNull()
|
|
62
|
+
expect(getRadarUpgradeRequirement({ radarUpgrade: { minimumVersion: 'v1.9.0' } })).toBeNull()
|
|
63
|
+
expect(getRadarUpgradeRequirement({ radarUpgrade: { feature: 'x', minimumVersion: 9 } })).toBeNull()
|
|
64
|
+
expect(getRadarUpgradeRequirement(null)).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { ArrowUpCircle, ExternalLink } from 'lucide-react'
|
|
4
|
+
import type { RadarUpgradeRequirement } from '../../types/fetch-error'
|
|
5
|
+
|
|
6
|
+
export interface RadarUpgradeActions {
|
|
7
|
+
/** Hands the upgrade to the host, e.g. Radar Cloud's role-aware upgrade flow. */
|
|
8
|
+
onRequestUpgrade?: (requirement: RadarUpgradeRequirement) => void
|
|
9
|
+
/** Used when there is no handler: a page with upgrade instructions. */
|
|
10
|
+
upgradeHref?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const RadarUpgradeContext = createContext<RadarUpgradeActions>({})
|
|
14
|
+
|
|
15
|
+
/** "v1.10.0" → "v1.10", "1.7.2" → "v1.7.2". Anything else is shown as given. */
|
|
16
|
+
export function formatRadarVersion(version: string): string {
|
|
17
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(version.trim())
|
|
18
|
+
if (!match) return version
|
|
19
|
+
const [, major, minor, patch] = match
|
|
20
|
+
return patch === '0' ? `v${major}.${minor}` : `v${major}.${minor}.${patch}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseVersion(version: string | undefined): number[] | null {
|
|
24
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version?.trim() ?? '')
|
|
25
|
+
return match ? match.slice(1).map(Number) : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isNewer(a: number[], b: number[]): boolean {
|
|
29
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Which Radar to ask for. The release that introduced the feature when it is
|
|
35
|
+
* known; otherwise the latest release, which serves everything this UI calls;
|
|
36
|
+
* otherwise just "a newer Radar".
|
|
37
|
+
*/
|
|
38
|
+
function target(requirement: RadarUpgradeRequirement): { since?: string; latest?: string } {
|
|
39
|
+
if (requirement.minimumVersion) return { since: formatRadarVersion(requirement.minimumVersion) }
|
|
40
|
+
const { latestVersion } = requirement
|
|
41
|
+
const latest = parseVersion(latestVersion)
|
|
42
|
+
const current = parseVersion(requirement.currentVersion)
|
|
43
|
+
if (latestVersion && latest && (!current || isNewer(latest, current))) {
|
|
44
|
+
return { latest: formatRadarVersion(latestVersion) }
|
|
45
|
+
}
|
|
46
|
+
return {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The Radar that is behind is the cluster's, not the UI the user is looking
|
|
50
|
+
// at, so the copy names the cluster. "It runs dev." says nothing useful, so
|
|
51
|
+
// only a real version is quoted.
|
|
52
|
+
function clusterVersion(requirement: RadarUpgradeRequirement): string | undefined {
|
|
53
|
+
const current = requirement.currentVersion?.trim()
|
|
54
|
+
return current && /^v?\d+\.\d+/.test(current) ? formatRadarVersion(current) : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Headline for a full-page state: "Capacity needs a newer Radar on this cluster". */
|
|
58
|
+
export function radarUpgradeHeadline(feature: string): string {
|
|
59
|
+
return `${feature} needs a newer Radar on this cluster`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Detail under that headline: "Available from Radar v1.9. This cluster runs v1.7.2." */
|
|
63
|
+
export function radarUpgradeDetail(requirement: RadarUpgradeRequirement): string {
|
|
64
|
+
const { since, latest } = target(requirement)
|
|
65
|
+
const current = clusterVersion(requirement)
|
|
66
|
+
return [
|
|
67
|
+
since ? `Available from Radar ${since}.` : latest ? `Available in the latest Radar, ${latest}.` : '',
|
|
68
|
+
current ? `This cluster runs ${current}.` : '',
|
|
69
|
+
].filter(Boolean).join(' ')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Follows the feature name in an inline note: "need Radar v1.10 or newer on this cluster. It runs v1.7.2." */
|
|
73
|
+
function radarUpgradeNeed(requirement: RadarUpgradeRequirement): string {
|
|
74
|
+
const { since, latest } = target(requirement)
|
|
75
|
+
const current = clusterVersion(requirement)
|
|
76
|
+
const need = since
|
|
77
|
+
? `need Radar ${since} or newer on this cluster.`
|
|
78
|
+
: latest
|
|
79
|
+
? `need the latest Radar (${latest}) on this cluster.`
|
|
80
|
+
: 'need a newer Radar on this cluster.'
|
|
81
|
+
return current ? `${need} It runs ${current}.` : need
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The upgrade call to action, or nothing when the host offers no way to upgrade. */
|
|
85
|
+
export function RadarUpgradeAction({ requirement }: { requirement: RadarUpgradeRequirement }) {
|
|
86
|
+
const { onRequestUpgrade, upgradeHref } = useContext(RadarUpgradeContext)
|
|
87
|
+
const className = 'inline-flex items-center gap-1 font-medium text-accent-text hover:underline'
|
|
88
|
+
if (onRequestUpgrade) {
|
|
89
|
+
return (
|
|
90
|
+
<button type="button" className={className} onClick={() => onRequestUpgrade(requirement)}>
|
|
91
|
+
Upgrade Radar
|
|
92
|
+
</button>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
if (upgradeHref) {
|
|
96
|
+
return (
|
|
97
|
+
<a href={upgradeHref} target="_blank" rel="noopener noreferrer" className={className}>
|
|
98
|
+
How to upgrade
|
|
99
|
+
<ExternalLink className="h-3 w-3" aria-hidden />
|
|
100
|
+
</a>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Inline note for a feature the connected Radar is too old to serve. Calm on
|
|
108
|
+
* purpose: nothing failed, the cluster just runs an older Radar than this UI.
|
|
109
|
+
*/
|
|
110
|
+
export function RadarUpgradeNote({
|
|
111
|
+
requirement,
|
|
112
|
+
className,
|
|
113
|
+
}: {
|
|
114
|
+
requirement: RadarUpgradeRequirement
|
|
115
|
+
className?: string
|
|
116
|
+
}) {
|
|
117
|
+
return (
|
|
118
|
+
<div className={clsx('flex items-start gap-2 text-xs text-theme-text-secondary', className)}>
|
|
119
|
+
<ArrowUpCircle className="mt-px h-3.5 w-3.5 shrink-0 text-accent-text" aria-hidden />
|
|
120
|
+
<p className="min-w-0">
|
|
121
|
+
<span className="font-medium text-theme-text-primary">{requirement.feature}</span>
|
|
122
|
+
{' '}
|
|
123
|
+
{radarUpgradeNeed(requirement)}
|
|
124
|
+
{' '}
|
|
125
|
+
<RadarUpgradeAction requirement={requirement} />
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -60,7 +60,7 @@ export function ResourceBar({
|
|
|
60
60
|
label,
|
|
61
61
|
}: ResourceBarProps) {
|
|
62
62
|
const track = (
|
|
63
|
-
<div className={clsx('relative', layout === 'inline' && 'min-w-
|
|
63
|
+
<div className={clsx('relative', layout === 'inline' && 'min-w-16 flex-1')}>
|
|
64
64
|
<div className={clsx('rounded-full border border-theme-border bg-theme-elevated overflow-hidden', layout === 'inline' ? 'h-1' : 'h-1.5')}>
|
|
65
65
|
<div
|
|
66
66
|
className={clsx('h-full rounded-full transition-[width] duration-300 ease-out', getBarColor(percent, colorScheme))}
|
|
@@ -78,7 +78,7 @@ export function ResourceBar({
|
|
|
78
78
|
|
|
79
79
|
const bar =
|
|
80
80
|
layout === 'inline' ? (
|
|
81
|
-
<div className=
|
|
81
|
+
<div className={clsx('flex items-center gap-1.5 min-w-0', tooltip && 'w-full')}>
|
|
82
82
|
<span className="w-7 shrink-0 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">{label}</span>
|
|
83
83
|
{track}
|
|
84
84
|
<span className="w-8 shrink-0 text-right text-[10.5px] font-mono tabular-nums text-theme-text-secondary">
|
|
@@ -86,7 +86,7 @@ export function ResourceBar({
|
|
|
86
86
|
</span>
|
|
87
87
|
</div>
|
|
88
88
|
) : (
|
|
89
|
-
<div className=
|
|
89
|
+
<div className={clsx('flex flex-col gap-0.5 min-w-0', tooltip && 'w-full')}>
|
|
90
90
|
<div className="flex items-baseline justify-between gap-1">
|
|
91
91
|
<span className="text-xs font-mono text-theme-text-secondary truncate">
|
|
92
92
|
{used} / {total}
|
|
@@ -99,11 +99,17 @@ export function ResourceBar({
|
|
|
99
99
|
</div>
|
|
100
100
|
)
|
|
101
101
|
|
|
102
|
+
// Tooltip's wrapper is inline-flex: it sizes the row to its content (hence
|
|
103
|
+
// w-full on the row, or the track collapses to the labels' width) and it is
|
|
104
|
+
// inline-level, so the block div keeps sibling bars stacked instead of
|
|
105
|
+
// sharing one line.
|
|
102
106
|
if (tooltip) {
|
|
103
107
|
return (
|
|
104
|
-
<
|
|
105
|
-
{
|
|
106
|
-
|
|
108
|
+
<div className="min-w-0">
|
|
109
|
+
<Tooltip content={tooltip} delay={200} position="top" wrapperClassName="w-full min-w-0">
|
|
110
|
+
{bar}
|
|
111
|
+
</Tooltip>
|
|
112
|
+
</div>
|
|
107
113
|
)
|
|
108
114
|
}
|
|
109
115
|
|
|
@@ -7,6 +7,8 @@ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../ut
|
|
|
7
7
|
export interface SelectMenuOption {
|
|
8
8
|
value: string
|
|
9
9
|
label: string
|
|
10
|
+
description?: string
|
|
11
|
+
disabled?: boolean
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function SelectMenu({
|
|
@@ -17,6 +19,8 @@ export function SelectMenu({
|
|
|
17
19
|
ariaDescribedBy,
|
|
18
20
|
className,
|
|
19
21
|
searchPlaceholder,
|
|
22
|
+
placeholder,
|
|
23
|
+
variant = 'default',
|
|
20
24
|
disabled = false,
|
|
21
25
|
id,
|
|
22
26
|
}: {
|
|
@@ -27,6 +31,8 @@ export function SelectMenu({
|
|
|
27
31
|
ariaDescribedBy?: string
|
|
28
32
|
className?: string
|
|
29
33
|
searchPlaceholder?: string
|
|
34
|
+
placeholder?: string
|
|
35
|
+
variant?: 'default' | 'text'
|
|
30
36
|
disabled?: boolean
|
|
31
37
|
id?: string
|
|
32
38
|
}) {
|
|
@@ -40,15 +46,33 @@ export function SelectMenu({
|
|
|
40
46
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
41
47
|
const listRef = useRef<HTMLDivElement>(null)
|
|
42
48
|
const pointerDownInsideRef = useRef(false)
|
|
43
|
-
|
|
49
|
+
// A keyboard open lands on the selected option, as a native select does; a
|
|
50
|
+
// pointer open keeps focus on the trigger.
|
|
51
|
+
const focusOptionOnOpenRef = useRef(false)
|
|
52
|
+
const selected = options.find((option) => option.value === value) ?? (placeholder ? undefined : options[0])
|
|
44
53
|
const filteredOptions = useMemo(() => {
|
|
45
54
|
const normalized = query.trim().toLowerCase()
|
|
46
55
|
if (!normalized) return options
|
|
47
|
-
return options.filter((option) => option.label.toLowerCase().includes(normalized))
|
|
56
|
+
return options.filter((option) => `${option.label} ${option.description ?? ''}`.toLowerCase().includes(normalized))
|
|
48
57
|
}, [options, query])
|
|
49
58
|
const selectedIsVisible = filteredOptions.some((option) => option.value === value)
|
|
50
59
|
|
|
60
|
+
const focusTabbableOption = () => {
|
|
61
|
+
listRef.current?.querySelector<HTMLElement>('[role="option"][tabindex="0"]')?.focus()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const openMenu = () => {
|
|
65
|
+
setHighlightedIndex(
|
|
66
|
+
Math.max(
|
|
67
|
+
options.findIndex((option) => option.value === value),
|
|
68
|
+
0,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
setOpen(true)
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
const selectOption = (nextValue: string) => {
|
|
75
|
+
if (options.find((option) => option.value === nextValue)?.disabled) return
|
|
52
76
|
onChange(nextValue)
|
|
53
77
|
setOpen(false)
|
|
54
78
|
triggerRef.current?.focus()
|
|
@@ -81,7 +105,12 @@ export function SelectMenu({
|
|
|
81
105
|
useEffect(() => {
|
|
82
106
|
if (!open || !shouldRender) return
|
|
83
107
|
if (!searchPlaceholder) {
|
|
84
|
-
|
|
108
|
+
if (focusOptionOnOpenRef.current) {
|
|
109
|
+
focusOptionOnOpenRef.current = false
|
|
110
|
+
focusTabbableOption()
|
|
111
|
+
} else {
|
|
112
|
+
triggerRef.current?.focus()
|
|
113
|
+
}
|
|
85
114
|
return
|
|
86
115
|
}
|
|
87
116
|
listRef.current
|
|
@@ -130,28 +159,38 @@ export function SelectMenu({
|
|
|
130
159
|
aria-expanded={open}
|
|
131
160
|
aria-controls={open ? listboxId : undefined}
|
|
132
161
|
onClick={() => {
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
162
|
+
if (open) setOpen(false)
|
|
163
|
+
else openMenu()
|
|
164
|
+
}}
|
|
165
|
+
onKeyDown={(event) => {
|
|
166
|
+
if (searchPlaceholder) return
|
|
167
|
+
const arrow = event.key === 'ArrowDown' || event.key === 'ArrowUp'
|
|
168
|
+
if (!arrow && (open || (event.key !== 'Enter' && event.key !== ' '))) return
|
|
169
|
+
event.preventDefault()
|
|
170
|
+
if (open) {
|
|
171
|
+
focusTabbableOption()
|
|
172
|
+
return
|
|
140
173
|
}
|
|
141
|
-
|
|
174
|
+
focusOptionOnOpenRef.current = true
|
|
175
|
+
openMenu()
|
|
142
176
|
}}
|
|
143
177
|
disabled={disabled}
|
|
144
|
-
className=
|
|
178
|
+
className={clsx(
|
|
179
|
+
'flex items-center gap-1 text-xs disabled:cursor-not-allowed disabled:opacity-50',
|
|
180
|
+
variant === 'text'
|
|
181
|
+
? 'text-accent-text hover:underline'
|
|
182
|
+
: 'h-8 w-full justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-theme-text-primary transition-colors hover:bg-theme-hover disabled:hover:bg-theme-elevated',
|
|
183
|
+
)}
|
|
145
184
|
>
|
|
146
|
-
<span className="truncate">{selected?.label}</span>
|
|
147
|
-
<ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary
|
|
185
|
+
<span className="truncate">{selected?.label ?? placeholder}</span>
|
|
186
|
+
<ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 transition-transform', variant !== 'text' && 'text-theme-text-tertiary', open && 'rotate-180')} />
|
|
148
187
|
</button>
|
|
149
188
|
{shouldRender && (
|
|
150
189
|
<div
|
|
151
190
|
inert={!open}
|
|
152
191
|
className={clsx(
|
|
153
192
|
'absolute top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
|
|
154
|
-
searchPlaceholder ? 'left-0 right-0 origin-top-left' : 'right-0 origin-top-right',
|
|
193
|
+
variant === 'text' ? 'left-0 w-72 max-w-[calc(100vw-4rem)] origin-top-left' : searchPlaceholder ? 'left-0 right-0 origin-top-left' : 'right-0 origin-top-right',
|
|
155
194
|
TRANSITION_MENU,
|
|
156
195
|
isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
|
|
157
196
|
!open && 'pointer-events-none',
|
|
@@ -177,8 +216,7 @@ export function SelectMenu({
|
|
|
177
216
|
onKeyDown={(event) => {
|
|
178
217
|
if (event.key === 'Enter' && filteredOptions.length > 0) {
|
|
179
218
|
event.preventDefault()
|
|
180
|
-
|
|
181
|
-
selectOption(filteredOptions[nextIndex].value)
|
|
219
|
+
selectOption(filteredOptions[Math.min(highlightedIndex, filteredOptions.length - 1)].value)
|
|
182
220
|
} else if (event.key === 'ArrowDown') {
|
|
183
221
|
event.preventDefault()
|
|
184
222
|
const optionElements = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]')
|
|
@@ -244,19 +282,24 @@ export function SelectMenu({
|
|
|
244
282
|
type="button"
|
|
245
283
|
role="option"
|
|
246
284
|
aria-selected={active}
|
|
285
|
+
aria-disabled={option.disabled || undefined}
|
|
247
286
|
tabIndex={
|
|
248
287
|
searchPlaceholder ? (index === highlightedIndex ? 0 : -1) : active || (!selectedIsVisible && index === 0) ? 0 : -1
|
|
249
288
|
}
|
|
250
289
|
onClick={() => selectOption(option.value)}
|
|
251
290
|
onFocus={() => setHighlightedIndex(index)}
|
|
252
291
|
className={clsx(
|
|
253
|
-
'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors
|
|
292
|
+
'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors',
|
|
293
|
+
option.disabled ? 'cursor-not-allowed opacity-60' : 'hover:bg-theme-hover hover:text-theme-text-primary',
|
|
254
294
|
searchPlaceholder && index === highlightedIndex && 'bg-theme-hover text-theme-text-primary',
|
|
255
295
|
!searchPlaceholder && 'whitespace-nowrap'
|
|
256
296
|
)}
|
|
257
297
|
>
|
|
258
298
|
<Check className={clsx('h-3.5 w-3.5 shrink-0 text-accent', !active && 'opacity-0')} />
|
|
259
|
-
<span className={clsx(searchPlaceholder && 'truncate')}>
|
|
299
|
+
<span className={clsx('min-w-0', searchPlaceholder && !option.description && 'truncate')}>
|
|
300
|
+
<span className={clsx(option.description && 'block break-words font-medium')}>{option.label}</span>
|
|
301
|
+
{option.description && <span className="block break-all text-theme-text-tertiary">{option.description}</span>}
|
|
302
|
+
</span>
|
|
260
303
|
</button>
|
|
261
304
|
)
|
|
262
305
|
})}
|