@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.
Files changed (72) hide show
  1. package/package.json +3 -3
  2. package/src/components/audit/AuditFindingsTable.tsx +1 -1
  3. package/src/components/checks/ChecksView.tsx +8 -8
  4. package/src/components/compare/CompareResourcePicker.tsx +4 -3
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
  6. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
  7. package/src/components/gitops/RollbackDialog.tsx +4 -3
  8. package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
  9. package/src/components/gitops/health-provenance.test.ts +1 -1
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
  12. package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
  13. package/src/components/issues/IssuesView.tsx +2 -2
  14. package/src/components/logs/LogCore.tsx +131 -97
  15. package/src/components/logs/StructuredLogLine.tsx +18 -28
  16. package/src/components/logs/log-palette.ts +4 -0
  17. package/src/components/logs/useLogBuffer.ts +8 -39
  18. package/src/components/logs/useLogSearch.ts +52 -24
  19. package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
  20. package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
  21. package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
  22. package/src/components/resources/KueueAdmissionSection.tsx +140 -51
  23. package/src/components/resources/ResourcesView.tsx +4 -0
  24. package/src/components/resources/printer-columns.test.ts +1 -1
  25. package/src/components/resources/renderers/JobRenderer.tsx +5 -1
  26. package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
  27. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
  28. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
  29. package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
  30. package/src/components/resources/renderers/PodRenderer.tsx +10 -2
  31. package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
  32. package/src/components/resources/renderers/PolicySection.tsx +10 -1
  33. package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
  34. package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
  35. package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
  36. package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
  37. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
  39. package/src/components/resources/renderers/index.ts +2 -0
  40. package/src/components/resources/resource-utils-kueue.ts +14 -0
  41. package/src/components/resources/resource-utils-ray.ts +14 -4
  42. package/src/components/shared/CreateResourceDialog.tsx +6 -2
  43. package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
  44. package/src/components/shared/DrainPlanDialog.tsx +14 -3
  45. package/src/components/shared/ResourceActionsBar.tsx +18 -11
  46. package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
  47. package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
  48. package/src/components/shared/SetImageDialog.tsx +4 -2
  49. package/src/components/shared/revision-role-badges.test.ts +1 -1
  50. package/src/components/trace/reachGraphModel.test.ts +1 -0
  51. package/src/components/trace/reachInspector.test.ts +4 -9
  52. package/src/components/ui/ConfirmDialog.tsx +5 -3
  53. package/src/components/ui/DialogPortal.test.tsx +279 -0
  54. package/src/components/ui/DialogPortal.tsx +147 -9
  55. package/src/components/ui/FilterPill.tsx +1 -1
  56. package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
  57. package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
  58. package/src/components/ui/RadarUpgradeNote.tsx +129 -0
  59. package/src/components/ui/ResourceBar.tsx +12 -6
  60. package/src/components/ui/SelectMenu.tsx +62 -19
  61. package/src/components/ui/YamlReview.tsx +4 -1
  62. package/src/components/ui/drawer-components.test.tsx +14 -1
  63. package/src/components/ui/drawer-components.tsx +50 -40
  64. package/src/components/ui/index.ts +8 -0
  65. package/src/components/workload/WorkloadView.tsx +109 -3
  66. package/src/types/core.ts +24 -4
  67. package/src/types/fetch-error.ts +33 -0
  68. package/src/utils/log-format.ts +1 -1
  69. package/src/utils/log-level.test.ts +269 -0
  70. package/src/utils/log-level.ts +301 -0
  71. package/src/utils/resource-hierarchy.test.ts +69 -0
  72. package/src/utils/resource-hierarchy.ts +87 -52
@@ -1,4 +1,10 @@
1
- import { useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
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({ open, onClose, children, className, closable = true }: DialogPortalProps) {
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
- // Move focus into the dialog for accessibility and tab navigation
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
- if (open && dialogRef.current) {
73
- dialogRef.current.focus()
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 className="fixed inset-0 z-50 flex items-center justify-center">
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-0 flex-1')}>
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="flex items-center gap-1.5 min-w-0">
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="flex flex-col gap-0.5 min-w-0">
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
- <Tooltip content={tooltip} delay={200} position="top" wrapperClassName="w-full min-w-0">
105
- {bar}
106
- </Tooltip>
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
- const selected = options.find((option) => option.value === value) ?? options[0]
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
- triggerRef.current?.focus()
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 (!open) {
134
- setHighlightedIndex(
135
- Math.max(
136
- options.findIndex((option) => option.value === value),
137
- 0,
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
- setOpen(!open)
174
+ focusOptionOnOpenRef.current = true
175
+ openMenu()
142
176
  }}
143
177
  disabled={disabled}
144
- className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-theme-elevated"
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 transition-transform', open && 'rotate-180')} />
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
- const nextIndex = Math.min(highlightedIndex, filteredOptions.length - 1)
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 hover:bg-theme-hover hover:text-theme-text-primary',
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')}>{option.label}</span>
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
  })}