@skyhook-io/k8s-ui 1.13.0 → 1.13.4

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 (110) hide show
  1. package/package.json +2 -2
  2. package/src/components/applications/AppTooltips.tsx +13 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +25 -0
  4. package/src/components/applications/ApplicationDetail.tsx +41 -9
  5. package/src/components/applications/ApplicationsView.tsx +18 -3
  6. package/src/components/applications/ReadyBar.tsx +3 -2
  7. package/src/components/applications/applications-view.test.tsx +91 -0
  8. package/src/components/cluster-switcher/ClusterSwitcher.tsx +35 -7
  9. package/src/components/issues/IssuesView.tsx +40 -31
  10. package/src/components/issues/ResourceIssuesSection.tsx +3 -2
  11. package/src/components/issues/index.ts +3 -1
  12. package/src/components/issues/issue-timing.ts +123 -4
  13. package/src/components/issues/issues.test.ts +221 -10
  14. package/src/components/issues/types.ts +31 -5
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
  16. package/src/components/resources/ResourcesView.tsx +973 -100
  17. package/src/components/resources/column-settings-key.test.ts +98 -0
  18. package/src/components/resources/column-visibility-merge.test.ts +64 -0
  19. package/src/components/resources/curated-column-ownership.test.ts +236 -0
  20. package/src/components/resources/gateway-policy-status.test.ts +291 -0
  21. package/src/components/resources/gateway-policy-status.ts +237 -0
  22. package/src/components/resources/generic-status.test.ts +199 -0
  23. package/src/components/resources/generic-status.ts +171 -0
  24. package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
  25. package/src/components/resources/index.ts +9 -1
  26. package/src/components/resources/istio-gateway-filter.test.ts +97 -0
  27. package/src/components/resources/printer-columns.test.ts +298 -0
  28. package/src/components/resources/printer-columns.ts +249 -0
  29. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  30. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  31. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  32. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  33. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  34. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  35. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  36. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  37. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  38. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  39. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  40. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  41. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  43. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  44. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  45. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  46. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  47. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  48. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  49. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  50. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  51. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  52. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  53. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  54. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  55. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  56. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  57. package/src/components/resources/resource-utils-hpa.ts +6 -2
  58. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  59. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  60. package/src/components/resources/resource-utils-kai.ts +105 -0
  61. package/src/components/resources/resource-utils-kaito.ts +114 -0
  62. package/src/components/resources/resource-utils-kserve.ts +186 -0
  63. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  64. package/src/components/resources/resource-utils-kueue.ts +198 -0
  65. package/src/components/resources/resource-utils-nim.ts +109 -0
  66. package/src/components/resources/resource-utils-ray.ts +214 -0
  67. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  68. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  69. package/src/components/resources/resource-utils-volcano.ts +176 -0
  70. package/src/components/resources/resource-utils.ts +156 -47
  71. package/src/components/resources/resources-column-filter.test.ts +27 -1
  72. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  73. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  74. package/src/components/shared/EditableYamlView.tsx +4 -2
  75. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  76. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  77. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  78. package/src/components/shared/SetImageDialog.test.ts +129 -0
  79. package/src/components/shared/SetImageDialog.tsx +577 -0
  80. package/src/components/shared/index.ts +1 -0
  81. package/src/components/shared/revision-role-badges.test.ts +56 -4
  82. package/src/components/timeline/TimelineList.tsx +19 -3
  83. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  84. package/src/components/trace/ReachabilityView.tsx +1 -1
  85. package/src/components/trace/TracePanel.tsx +1 -0
  86. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  87. package/src/components/ui/SelectMenu.tsx +202 -28
  88. package/src/components/ui/drawer-components.tsx +12 -0
  89. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  90. package/src/components/workload/WorkloadView.tsx +146 -83
  91. package/src/hooks/useProgressiveRefresh.ts +37 -0
  92. package/src/source-hygiene.test.ts +28 -0
  93. package/src/theme/tailwind-theme.css +4 -0
  94. package/src/types/core.ts +62 -4
  95. package/src/utils/api-resources.test.ts +40 -1
  96. package/src/utils/api-resources.ts +11 -1
  97. package/src/utils/applications.ts +50 -0
  98. package/src/utils/bulk-workload-actions.test.ts +20 -0
  99. package/src/utils/bulk-workload-actions.ts +15 -0
  100. package/src/utils/clipboard.test.ts +86 -0
  101. package/src/utils/clipboard.ts +50 -0
  102. package/src/utils/extended-resources.ts +4 -7
  103. package/src/utils/format.test.ts +83 -1
  104. package/src/utils/format.ts +80 -1
  105. package/src/utils/inClusterConsent.test.ts +9 -0
  106. package/src/utils/inClusterConsent.ts +9 -9
  107. package/src/utils/index.ts +1 -0
  108. package/src/utils/navigation.test.ts +31 -1
  109. package/src/utils/navigation.ts +21 -2
  110. package/src/utils/workload-rollout.ts +292 -0
@@ -30,7 +30,7 @@ import { isChangeEvent, isHistoricalEvent } from '../../types'
30
30
  import { getHealthBadgeColor, SEVERITY_BADGE } from '../../utils/badge-colors'
31
31
  import { ResourceRefBadge } from '../ui/drawer-components'
32
32
  import type { NavigateToResource } from '../../utils/navigation'
33
- import { kindToPlural, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
33
+ import { kindToPluralWithGroup, refToSelectedResource, apiVersionToGroup } from '../../utils/navigation'
34
34
  import { pluralize } from '../../utils/pluralize'
35
35
 
36
36
  /** Format resource age (e.g., "3d", "5h", "10m") */
@@ -604,7 +604,15 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick, compact, sele
604
604
  <button
605
605
  onClick={(e) => {
606
606
  e.stopPropagation()
607
- onResourceClick?.({ kind: kindToPlural(item.kind), namespace: item.namespace, name: item.name, group: apiVersionToGroup(item.apiVersion) })
607
+ onResourceClick?.({
608
+ kind: kindToPluralWithGroup(
609
+ item.kind,
610
+ apiVersionToGroup(item.apiVersion),
611
+ ),
612
+ namespace: item.namespace,
613
+ name: item.name,
614
+ group: apiVersionToGroup(item.apiVersion),
615
+ })
608
616
  }}
609
617
  className="flex items-center gap-2 hover:bg-theme-elevated/50 rounded px-1 -ml-1 transition-colors group"
610
618
  >
@@ -777,7 +785,15 @@ function AggregatedActivityCard({ first, last, count, reason, expanded, onToggle
777
785
  <button
778
786
  onClick={(e) => {
779
787
  e.stopPropagation()
780
- onResourceClick?.({ kind: kindToPlural(first.kind), namespace: first.namespace, name: first.name, group: apiVersionToGroup(first.apiVersion) })
788
+ onResourceClick?.({
789
+ kind: kindToPluralWithGroup(
790
+ first.kind,
791
+ apiVersionToGroup(first.apiVersion),
792
+ ),
793
+ namespace: first.namespace,
794
+ name: first.name,
795
+ group: apiVersionToGroup(first.apiVersion),
796
+ })
781
797
  }}
782
798
  className="flex items-center gap-2 hover:bg-theme-elevated/50 rounded px-1 -ml-1 transition-colors group"
783
799
  >
@@ -26,7 +26,7 @@ import {
26
26
  } from 'lucide-react'
27
27
  import type { TimelineEvent, Topology } from '../../types'
28
28
  import type { NavigateToResource } from '../../utils/navigation'
29
- import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
29
+ import { kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
30
30
  import { PaneLoader } from '../ui/PaneLoader'
31
31
  import { TimelineToolbar } from './TimelineToolbar'
32
32
  import { sortTimelineLanes, TIMELINE_SORT_DEFAULT, type TimelineSort } from './timeline-lane-sort'
@@ -770,7 +770,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
770
770
  onNavigatePath(gitOpsPath)
771
771
  return
772
772
  }
773
- onResourceClick?.({ kind: kindToPlural(kind), namespace, name, group })
773
+ onResourceClick?.({ kind: kindToPluralWithGroup(kind, group ?? ''), namespace, name, group })
774
774
  }, [onNavigatePath, onResourceClick])
775
775
  const containerRef = useRef<HTMLDivElement>(null)
776
776
  const [zoom, setZoom] = useState(1)
@@ -3097,7 +3097,15 @@ export function EventDetailPanel({ events, selectedId, onSelectId, onClose, onRe
3097
3097
  // divider separates "the dot you clicked" from "what happened around it".
3098
3098
  const neighborsFrom = events.length === 1 && railEvents.length > 1 ? 1 : -1
3099
3099
  const openResource = () =>
3100
- onResourceClick?.({ kind: kindToPlural(selected.kind), namespace: selected.namespace, name: selected.name, group: apiVersionToGroup(selected.apiVersion) })
3100
+ onResourceClick?.({
3101
+ kind: kindToPluralWithGroup(
3102
+ selected.kind,
3103
+ apiVersionToGroup(selected.apiVersion),
3104
+ ),
3105
+ namespace: selected.namespace,
3106
+ name: selected.name,
3107
+ group: apiVersionToGroup(selected.apiVersion),
3108
+ })
3101
3109
 
3102
3110
  return (
3103
3111
  <div
@@ -535,7 +535,7 @@ export function problemRows(
535
535
  // entry can carry several findings, and one origin several failed routes.
536
536
  // `k` is what `seen` just proved unique, so pairing them is what makes the
537
537
  // React key safe rather than each caller having to get it right.
538
- rows.push({ ...r, key: `${r.key}${k}` })
538
+ rows.push({ ...r, key: `${r.key}\x00${k}` })
539
539
  }
540
540
  const refNode = (r?: ResourceRef) => (r ? `n:${r.kind}/${r.namespace ?? ''}/${r.name || 'pods'}` : '')
541
541
 
@@ -87,6 +87,7 @@ export interface InClusterCapability {
87
87
  allowed: boolean
88
88
  reason?: string
89
89
  cluster?: string
90
+ clusterKey?: string
90
91
  namespace: string
91
92
  /** Per-call ceiling on probe Pods. Routes past it never run - ordering is
92
93
  * deterministic, so re-running starts from the same first maxProbes. */
@@ -13,6 +13,7 @@ export interface ConsentRequest {
13
13
  interface InClusterConsentDialogProps {
14
14
  open: boolean
15
15
  cluster?: string
16
+ clusterKey?: string
16
17
  namespace: string
17
18
  /** The requests this run will send, one per declared route. */
18
19
  requests?: ConsentRequest[]
@@ -85,7 +86,7 @@ function cluster(name?: string): string {
85
86
  // Confirms the mutating in-cluster reachability test before it spawns a Job/pod,
86
87
  // naming the cluster it lands in. Permission is enforced upstream (the button only
87
88
  // renders when the capability SSAR allows), so this is a safety confirm, not authz.
88
- export function InClusterConsentDialog({ open, cluster, namespace, requests, untestedCount, maxProbes, onClose, onConfirm }: InClusterConsentDialogProps) {
89
+ export function InClusterConsentDialog({ open, cluster, clusterKey, namespace, requests, untestedCount, maxProbes, onClose, onConfirm }: InClusterConsentDialogProps) {
89
90
  const [dontAskAgain, setDontAskAgain] = useState(false)
90
91
 
91
92
  function handleClose() {
@@ -94,7 +95,7 @@ export function InClusterConsentDialog({ open, cluster, namespace, requests, unt
94
95
  }
95
96
 
96
97
  function handleConfirm() {
97
- if (dontAskAgain && cluster) rememberInClusterConsent(cluster)
98
+ if (dontAskAgain && clusterKey) rememberInClusterConsent(clusterKey)
98
99
  setDontAskAgain(false)
99
100
  onConfirm()
100
101
  }
@@ -117,11 +118,11 @@ export function InClusterConsentDialog({ open, cluster, namespace, requests, unt
117
118
  cancelLabel="Cancel"
118
119
  variant="warning"
119
120
  >
120
- {/* "Don't ask again" is stored per cluster. Without a cluster identity
121
+ {/* "Don't ask again" is stored per cluster source. Without a source identity
121
122
  there is nothing safe to key it on (one origin can front many
122
123
  clusters), so consent is never remembered - hide the checkbox rather
123
124
  than promise a persistence that doesn't stick. */}
124
- {cluster ? (
125
+ {clusterKey ? (
125
126
  <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
126
127
  <input
127
128
  type="checkbox"
@@ -1,5 +1,5 @@
1
- import { useEffect, useRef, useState } from 'react'
2
- import { Check, ChevronDown } from 'lucide-react'
1
+ import { useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import { Check, ChevronDown, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
 
5
5
  export interface SelectMenuOption {
@@ -13,67 +13,241 @@ export function SelectMenu({
13
13
  onChange,
14
14
  ariaLabel,
15
15
  className,
16
+ searchPlaceholder,
16
17
  }: {
17
18
  value: string
18
19
  options: SelectMenuOption[]
19
20
  onChange: (value: string) => void
20
21
  ariaLabel: string
21
22
  className?: string
23
+ searchPlaceholder?: string
22
24
  }) {
23
25
  const [open, setOpen] = useState(false)
26
+ const [query, setQuery] = useState('')
27
+ const [highlightedIndex, setHighlightedIndex] = useState(0)
28
+ const listboxId = useId()
24
29
  const rootRef = useRef<HTMLDivElement>(null)
30
+ const triggerRef = useRef<HTMLButtonElement>(null)
31
+ const searchInputRef = useRef<HTMLInputElement>(null)
32
+ const listRef = useRef<HTMLDivElement>(null)
33
+ const pointerDownInsideRef = useRef(false)
25
34
  const selected = options.find((option) => option.value === value) ?? options[0]
35
+ const filteredOptions = useMemo(() => {
36
+ const normalized = query.trim().toLowerCase()
37
+ if (!normalized) return options
38
+ return options.filter((option) => option.label.toLowerCase().includes(normalized))
39
+ }, [options, query])
40
+ const selectedIsVisible = filteredOptions.some((option) => option.value === value)
41
+
42
+ const selectOption = (nextValue: string) => {
43
+ onChange(nextValue)
44
+ setOpen(false)
45
+ triggerRef.current?.focus()
46
+ }
26
47
 
27
48
  useEffect(() => {
28
49
  if (!open) return
29
50
  const onPointerDown = (event: MouseEvent) => {
30
51
  if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
31
52
  }
32
- const onKeyDown = (event: KeyboardEvent) => {
33
- if (event.key === 'Escape') setOpen(false)
34
- }
35
53
  document.addEventListener('mousedown', onPointerDown)
36
- document.addEventListener('keydown', onKeyDown)
37
54
  return () => {
38
55
  document.removeEventListener('mousedown', onPointerDown)
39
- document.removeEventListener('keydown', onKeyDown)
40
56
  }
41
57
  }, [open])
42
58
 
59
+ useEffect(() => {
60
+ if (!open) setQuery('')
61
+ }, [open])
62
+
63
+ useEffect(() => {
64
+ if (!open || !query) return
65
+ if (listRef.current) listRef.current.scrollTop = 0
66
+ }, [open, query])
67
+
68
+ useEffect(() => {
69
+ if (!open) return
70
+ if (!searchPlaceholder) {
71
+ triggerRef.current?.focus()
72
+ return
73
+ }
74
+ listRef.current
75
+ ?.querySelector<HTMLElement>('[role="option"][aria-selected="true"]')
76
+ ?.scrollIntoView({ block: 'nearest' })
77
+ }, [open, searchPlaceholder])
78
+
43
79
  return (
44
- <div ref={rootRef} className={clsx('relative', className)}>
80
+ <div
81
+ ref={rootRef}
82
+ className={clsx('relative', className)}
83
+ onMouseDownCapture={() => {
84
+ pointerDownInsideRef.current = true
85
+ requestAnimationFrame(() => {
86
+ pointerDownInsideRef.current = false
87
+ })
88
+ }}
89
+ onKeyDown={(event) => {
90
+ if (event.key !== 'Escape' || !open) return
91
+ event.preventDefault()
92
+ event.stopPropagation()
93
+ setOpen(false)
94
+ triggerRef.current?.focus()
95
+ }}
96
+ onBlur={(event) => {
97
+ if (!open) return
98
+ if (event.relatedTarget && rootRef.current?.contains(event.relatedTarget)) return
99
+ if (!pointerDownInsideRef.current) {
100
+ setOpen(false)
101
+ return
102
+ }
103
+ requestAnimationFrame(() => {
104
+ if (!document.hasFocus() || !listRef.current) return
105
+ if (searchPlaceholder) searchInputRef.current?.focus()
106
+ else triggerRef.current?.focus()
107
+ })
108
+ }}
109
+ >
45
110
  <button
111
+ ref={triggerRef}
46
112
  type="button"
47
113
  aria-label={ariaLabel}
48
114
  aria-haspopup="listbox"
49
115
  aria-expanded={open}
50
- onClick={() => setOpen((current) => !current)}
116
+ aria-controls={open ? listboxId : undefined}
117
+ onClick={() => {
118
+ if (!open) {
119
+ setHighlightedIndex(
120
+ Math.max(
121
+ options.findIndex((option) => option.value === value),
122
+ 0,
123
+ ),
124
+ )
125
+ }
126
+ setOpen(!open)
127
+ }}
51
128
  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"
52
129
  >
53
130
  <span className="truncate">{selected?.label}</span>
54
131
  <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
55
132
  </button>
56
133
  {open && (
57
- <div role="listbox" className="absolute right-0 top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface py-1 shadow-theme-lg">
58
- {options.map((option) => {
59
- const active = option.value === value
60
- return (
61
- <button
62
- key={option.value}
63
- type="button"
64
- role="option"
65
- aria-selected={active}
66
- onClick={() => {
67
- onChange(option.value)
68
- setOpen(false)
134
+ <div
135
+ className={clsx(
136
+ 'absolute top-full z-50 mt-1 min-w-full overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
137
+ searchPlaceholder ? 'left-0 right-0' : 'right-0'
138
+ )}
139
+ >
140
+ {searchPlaceholder && (
141
+ <div
142
+ className="flex items-center gap-2 border-b border-theme-border px-2.5 py-2"
143
+ onMouseDown={(event) => {
144
+ if (event.target !== searchInputRef.current) event.preventDefault()
145
+ }}
146
+ >
147
+ <Search className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
148
+ <input
149
+ ref={searchInputRef}
150
+ autoFocus
151
+ value={query}
152
+ onChange={(event) => {
153
+ setQuery(event.target.value)
154
+ setHighlightedIndex(0)
69
155
  }}
70
- className="flex w-full items-center gap-2 whitespace-nowrap 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"
71
- >
72
- <Check className={clsx('h-3.5 w-3.5 shrink-0 text-accent', !active && 'opacity-0')} />
73
- {option.label}
74
- </button>
75
- )
76
- })}
156
+ onKeyDown={(event) => {
157
+ if (event.key === 'Enter' && filteredOptions.length > 0) {
158
+ event.preventDefault()
159
+ const nextIndex = Math.min(highlightedIndex, filteredOptions.length - 1)
160
+ selectOption(filteredOptions[nextIndex].value)
161
+ } else if (event.key === 'ArrowDown') {
162
+ event.preventDefault()
163
+ const optionElements = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]')
164
+ optionElements?.[Math.min(highlightedIndex, optionElements.length - 1)]?.focus()
165
+ }
166
+ }}
167
+ aria-label={searchPlaceholder}
168
+ role="combobox"
169
+ aria-autocomplete="list"
170
+ aria-controls={listboxId}
171
+ aria-expanded="true"
172
+ aria-activedescendant={
173
+ filteredOptions.length > 0
174
+ ? `${listboxId}-option-${Math.min(highlightedIndex, filteredOptions.length - 1)}`
175
+ : undefined
176
+ }
177
+ placeholder={searchPlaceholder}
178
+ className="min-w-0 flex-1 bg-transparent text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary"
179
+ />
180
+ </div>
181
+ )}
182
+ {searchPlaceholder && (
183
+ <span className="sr-only" aria-live="polite">
184
+ {filteredOptions.length === 0
185
+ ? 'No matches.'
186
+ : `${filteredOptions.length} ${filteredOptions.length === 1 ? 'option' : 'options'} available.`}
187
+ </span>
188
+ )}
189
+ <div
190
+ id={listboxId}
191
+ ref={listRef}
192
+ role="listbox"
193
+ aria-label={ariaLabel}
194
+ onKeyDown={(event) => {
195
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
196
+ const optionElements = Array.from(
197
+ listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? [],
198
+ )
199
+ const currentIndex = optionElements.indexOf(event.target as HTMLButtonElement)
200
+ if (currentIndex < 0) return
201
+ event.preventDefault()
202
+ if (event.key === 'ArrowUp' && currentIndex === 0 && searchPlaceholder) {
203
+ searchInputRef.current?.focus()
204
+ return
205
+ }
206
+ const nextIndex = event.key === 'Home'
207
+ ? 0
208
+ : event.key === 'End'
209
+ ? optionElements.length - 1
210
+ : event.key === 'ArrowDown'
211
+ ? Math.min(currentIndex + 1, optionElements.length - 1)
212
+ : Math.max(currentIndex - 1, 0)
213
+ optionElements[nextIndex]?.focus()
214
+ }}
215
+ className={clsx(filteredOptions.length > 0 && 'py-1', searchPlaceholder && 'max-h-64 overflow-y-auto')}
216
+ >
217
+ {filteredOptions.map((option, index) => {
218
+ const active = option.value === value
219
+ return (
220
+ <button
221
+ key={option.value}
222
+ id={`${listboxId}-option-${index}`}
223
+ type="button"
224
+ role="option"
225
+ aria-selected={active}
226
+ tabIndex={
227
+ searchPlaceholder ? (index === highlightedIndex ? 0 : -1) : active || (!selectedIsVisible && index === 0) ? 0 : -1
228
+ }
229
+ onClick={() => selectOption(option.value)}
230
+ onFocus={() => setHighlightedIndex(index)}
231
+ className={clsx(
232
+ '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',
233
+ searchPlaceholder && index === highlightedIndex && 'bg-theme-hover text-theme-text-primary',
234
+ !searchPlaceholder && 'whitespace-nowrap'
235
+ )}
236
+ >
237
+ <Check className={clsx('h-3.5 w-3.5 shrink-0 text-accent', !active && 'opacity-0')} />
238
+ <span className={clsx(searchPlaceholder && 'truncate')}>{option.label}</span>
239
+ </button>
240
+ )
241
+ })}
242
+ </div>
243
+ {filteredOptions.length === 0 && (
244
+ <p
245
+ onMouseDown={(event) => event.preventDefault()}
246
+ className="px-3 py-2 text-xs text-theme-text-tertiary"
247
+ >
248
+ No matches.
249
+ </p>
250
+ )}
77
251
  </div>
78
252
  )}
79
253
  </div>
@@ -733,6 +733,18 @@ export function formatKindName(kind: string): string {
733
733
  resourceclaims: 'ResourceClaim', resourceclaimtemplates: 'ResourceClaimTemplate',
734
734
  deviceclasses: 'DeviceClass', resourceslices: 'ResourceSlice',
735
735
  clusterpolicies: 'ClusterPolicy', nvidiadrivers: 'NVIDIADriver',
736
+ clusterqueues: 'ClusterQueue', localqueues: 'LocalQueue', resourceflavors: 'ResourceFlavor',
737
+ admissionchecks: 'AdmissionCheck', provisioningrequests: 'ProvisioningRequest',
738
+ rayclusters: 'RayCluster', rayjobs: 'RayJob', rayservices: 'RayService', raycronjobs: 'RayCronJob',
739
+ leaderworkersets: 'LeaderWorkerSet', jobsets: 'JobSet',
740
+ inferenceservices: 'InferenceService', servingruntimes: 'ServingRuntime',
741
+ clusterservingruntimes: 'ClusterServingRuntime', inferencegraphs: 'InferenceGraph',
742
+ trainedmodels: 'TrainedModel', llminferenceservices: 'LLMInferenceService',
743
+ inferencepools: 'InferencePool', inferenceobjectives: 'InferenceObjective',
744
+ jobflows: 'JobFlow', jobtemplates: 'JobTemplate', ragengines: 'RAGEngine',
745
+ nimservices: 'NIMService', nimcaches: 'NIMCache', nimpipelines: 'NIMPipeline',
746
+ deviceconfigs: 'DeviceConfig',
747
+ pytorchjobs: 'PyTorchJob', tfjobs: 'TFJob', mpijobs: 'MPIJob', trainjobs: 'TrainJob',
736
748
  }
737
749
  if (names[k]) return names[k]
738
750
 
@@ -0,0 +1,60 @@
1
+ import { AlertTriangle, Check, Clock3, Loader2, Pause } from 'lucide-react'
2
+ import { Badge, type BadgeSeverity } from '../ui/Badge'
3
+ import type { WorkloadRolloutActivity } from '../../utils/workload-rollout'
4
+
5
+ export function WorkloadRolloutNotice({
6
+ activity,
7
+ recentImageSave = false,
8
+ className = '',
9
+ }: {
10
+ activity: WorkloadRolloutActivity
11
+ recentImageSave?: boolean
12
+ className?: string
13
+ }) {
14
+ if (activity.phase === 'idle' && !recentImageSave) return null
15
+ const shown = activity.phase === 'idle' && recentImageSave
16
+ ? { ...activity, phase: 'applying' as const, active: true, label: 'Waiting for controller', detail: 'The template change was accepted; waiting for live rollout status' }
17
+ : activity
18
+ const severity: BadgeSeverity = shown.phase === 'stalled'
19
+ ? 'error'
20
+ : shown.phase === 'paused' || shown.manual
21
+ ? 'warning'
22
+ : 'info'
23
+ const Icon = shown.phase === 'stalled'
24
+ ? AlertTriangle
25
+ : shown.phase === 'paused'
26
+ ? Pause
27
+ : shown.phase === 'waiting'
28
+ ? Clock3
29
+ : Loader2
30
+ const warning = shown.phase === 'paused' || shown.manual
31
+ const containerTone = shown.phase === 'stalled'
32
+ ? 'border-semantic-error/30 bg-semantic-error/5'
33
+ : warning
34
+ ? 'border-semantic-warning/30 bg-semantic-warning/5'
35
+ : 'border-theme-border bg-accent-muted'
36
+ const iconTone = shown.phase === 'stalled'
37
+ ? 'text-semantic-error'
38
+ : warning
39
+ ? 'text-semantic-warning'
40
+ : 'text-accent-text'
41
+
42
+ return (
43
+ <div className={`rounded-lg border px-3 py-2.5 shadow-theme-sm ${containerTone} ${className}`} role={shown.phase === 'stalled' ? 'alert' : 'status'}>
44
+ <div className="flex min-w-0 items-start gap-2.5">
45
+ <Icon className={`mt-0.5 h-4 w-4 shrink-0 ${iconTone} ${shown.active && (shown.phase === 'applying' || shown.phase === 'progressing') ? 'animate-spin' : ''}`} />
46
+ <div className="min-w-0 flex-1">
47
+ <div className="flex min-w-0 flex-wrap items-center gap-1.5">
48
+ {recentImageSave && (
49
+ <span className="inline-flex items-center gap-1 text-xs font-medium text-theme-text-secondary">
50
+ <Check className="h-3.5 w-3.5" /> Image saved <span aria-hidden>·</span>
51
+ </span>
52
+ )}
53
+ <Badge severity={severity}>{shown.label}</Badge>
54
+ </div>
55
+ {shown.detail && <div className="mt-1 text-xs leading-5 text-theme-text-secondary">{shown.detail}</div>}
56
+ </div>
57
+ </div>
58
+ </div>
59
+ )
60
+ }