@skyhook-io/k8s-ui 1.17.0 → 1.17.1

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 (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
@@ -28,12 +28,16 @@ export interface ApplyResult {
28
28
  namespace: string
29
29
  kind: string
30
30
  apiVersion?: string
31
+ uid?: string
31
32
  created: boolean
32
33
  }
33
34
 
34
35
  export interface CreateResourceDialogProps {
35
36
  open: boolean
36
37
  onClose: () => void
38
+ /** Return the current draft to a parent flow without closing that flow. */
39
+ onBack?: (yaml: string) => void
40
+ backLabel?: string
37
41
  initialYaml?: string
38
42
  title?: string
39
43
  onApply: (params: {
@@ -53,12 +57,19 @@ export interface CreateResourceDialogProps {
53
57
  isPreviewing?: boolean
54
58
  previewError?: string | null
55
59
  schemaLoader?: YamlSchemaLoader
56
- onCreated?: (result: ApplyResult) => void
60
+ /** Receives the first result and the exact submitted manifest, after a successful write. */
61
+ onCreated?: (result: ApplyResult, submittedYaml: string) => void
62
+ /** Start in strict create mode (e.g. a prefilled manifest that must not update an existing object). */
63
+ initialMode?: 'apply' | 'create'
64
+ /** Stay in `initialMode` with the Apply/Create choice and Force hidden, even after a partial create (e.g. a new object that must only be created). */
65
+ lockMode?: boolean
57
66
  }
58
67
 
59
68
  export function CreateResourceDialog({
60
69
  open,
61
70
  onClose,
71
+ onBack,
72
+ backLabel = 'Back',
62
73
  initialYaml = '',
63
74
  title,
64
75
  onApply,
@@ -68,15 +79,18 @@ export function CreateResourceDialog({
68
79
  previewError,
69
80
  schemaLoader,
70
81
  onCreated,
82
+ initialMode = 'apply',
83
+ lockMode = false,
71
84
  }: CreateResourceDialogProps) {
72
85
  const titleId = useId()
73
86
  const [yaml, setYaml] = useState(initialYaml)
74
- const [mode, setMode] = useState<'apply' | 'create'>('apply')
87
+ const [mode, setMode] = useState<'apply' | 'create'>(initialMode)
75
88
  const [dryRun, setDryRun] = useState(false)
76
89
  const [force, setForce] = useState(false)
77
90
  const [yamlValid, setYamlValid] = useState(true)
78
91
  const [error, setError] = useState<string | null>(null)
79
92
  const [success, setSuccess] = useState<string | null>(null)
93
+ const [partialCreates, setPartialCreates] = useState<ApplyResult[]>([])
80
94
  const [preview, setPreview] = useState<{
81
95
  yaml: string
82
96
  mode: 'apply' | 'create'
@@ -85,6 +99,7 @@ export function CreateResourceDialog({
85
99
  nonAtomic: boolean
86
100
  context?: string
87
101
  } | null>(null)
102
+ const closeTimer = useRef<number | undefined>(undefined)
88
103
  const [drag, setDrag] = useState<FileDragState>('none')
89
104
  const [pendingImport, setPendingImport] = useState<{ fileName: string; yaml: string } | null>(null)
90
105
  const [importing, setImporting] = useState<string | null>(null)
@@ -110,7 +125,11 @@ export function CreateResourceDialog({
110
125
  scheduledFrames.current.clear()
111
126
  }, [])
112
127
 
113
- useEffect(() => retireDeferredImports, [retireDeferredImports])
128
+ const cancelScheduledClose = useCallback(() => {
129
+ window.clearTimeout(closeTimer.current)
130
+ closeTimer.current = undefined
131
+ }, [])
132
+ useEffect(() => () => { retireDeferredImports(); cancelScheduledClose() }, [retireDeferredImports, cancelScheduledClose])
114
133
 
115
134
  const scheduleFrame = useCallback((generation: number, run: () => void) => {
116
135
  const id = requestAnimationFrame(() => {
@@ -123,12 +142,14 @@ export function CreateResourceDialog({
123
142
 
124
143
  useEffect(() => {
125
144
  if (!open) return
145
+ cancelScheduledClose()
126
146
  setYaml(initialYaml)
127
- setMode('apply')
147
+ setMode(initialMode)
128
148
  setDryRun(false)
129
149
  setForce(false)
130
150
  setYamlValid(true)
131
151
  setError(null)
152
+ setPartialCreates([])
132
153
  setSuccess(null)
133
154
  setPreview(null)
134
155
  setDrag('none')
@@ -136,19 +157,21 @@ export function CreateResourceDialog({
136
157
  setImporting(null)
137
158
  dragDepth.current = 0
138
159
  retireDeferredImports()
139
- }, [open, initialYaml, retireDeferredImports])
160
+ }, [open, initialYaml, initialMode, retireDeferredImports, cancelScheduledClose])
140
161
 
141
162
  const pending = isApplying || isPreviewing
142
163
  const closeNow = useCallback(() => {
164
+ cancelScheduledClose()
143
165
  retireDeferredImports()
144
166
  setError(null)
167
+ setPartialCreates([])
145
168
  setSuccess(null)
146
169
  setPreview(null)
147
170
  setPendingImport(null)
148
171
  setImporting(null)
149
172
  setYaml('')
150
173
  onClose()
151
- }, [onClose, retireDeferredImports])
174
+ }, [onClose, retireDeferredImports, cancelScheduledClose])
152
175
  const handleClose = useCallback(() => {
153
176
  if (!pending) closeNow()
154
177
  }, [closeNow, pending])
@@ -158,7 +181,7 @@ export function CreateResourceDialog({
158
181
  }, [])
159
182
 
160
183
  const finishApply = useCallback(
161
- (results: ApplyResult[], appliedMode: 'apply' | 'create', wasDryRun: boolean) => {
184
+ (results: ApplyResult[], appliedMode: 'apply' | 'create', wasDryRun: boolean, submittedYaml: string) => {
162
185
  const action = appliedMode === 'create' ? 'Created' : 'Applied'
163
186
  const dryRunLabel = wasDryRun ? ' (dry run)' : ''
164
187
  if (results.length === 1) {
@@ -170,11 +193,12 @@ export function CreateResourceDialog({
170
193
  setSuccess(`${action} ${results.length} resources${dryRunLabel}`)
171
194
  }
172
195
  if (wasDryRun) return
196
+ setPartialCreates([])
173
197
  if (onCreated && results.length > 0) {
174
198
  closeNow()
175
- onCreated(results[0])
199
+ onCreated(results[0], submittedYaml)
176
200
  } else {
177
- window.setTimeout(closeNow, 1200)
201
+ closeTimer.current = window.setTimeout(closeNow, 1200)
178
202
  }
179
203
  },
180
204
  [closeNow, onCreated],
@@ -274,6 +298,17 @@ export function CreateResourceDialog({
274
298
  [loadFiles],
275
299
  )
276
300
 
301
+ const recoverPartialCreate = useCallback((caught: unknown, submittedYaml: string, appliedMode: 'apply' | 'create', wasDryRun: boolean) => {
302
+ const appliedResults = caught instanceof Error && 'appliedResults' in caught && Array.isArray(caught.appliedResults) ? caught.appliedResults : []
303
+ const createdResults = appliedMode === 'create' ? appliedResults : appliedResults.filter(result => result.created)
304
+ if (wasDryRun || createdResults.length === 0) return false
305
+ setYaml(submittedYaml)
306
+ if (!lockMode && onPreview) setMode('apply')
307
+ setPreview(null)
308
+ setPartialCreates(previous => [...previous, ...createdResults])
309
+ return true
310
+ }, [lockMode, onPreview])
311
+
277
312
  const handleSubmit = useCallback(async () => {
278
313
  if (!yaml.trim()) {
279
314
  setError('YAML content is required')
@@ -301,11 +336,12 @@ export function CreateResourceDialog({
301
336
  dryRun,
302
337
  force: mode === 'apply' && force,
303
338
  })
304
- finishApply(results, mode, dryRun)
339
+ finishApply(results, mode, dryRun, yaml)
305
340
  } catch (caught) {
306
341
  setError(caught instanceof Error ? caught.message : 'Unknown error')
342
+ if (!onPreview) recoverPartialCreate(caught, yaml, mode, dryRun)
307
343
  }
308
- }, [yaml, mode, dryRun, force, onApply, onPreview, finishApply])
344
+ }, [yaml, mode, dryRun, force, onApply, onPreview, finishApply, recoverPartialCreate])
309
345
 
310
346
  const handleApplyReviewed = useCallback(async () => {
311
347
  if (!preview) return
@@ -320,25 +356,11 @@ export function CreateResourceDialog({
320
356
  reviewedResourceVersions,
321
357
  reviewedContext: preview.context,
322
358
  })
323
- finishApply(results, preview.mode, false)
359
+ finishApply(results, preview.mode, false, preview.yaml)
324
360
  } catch (caught) {
325
361
  const message = caught instanceof Error ? caught.message : 'Unknown error'
326
- const appliedResults =
327
- caught instanceof Error &&
328
- 'appliedResults' in caught &&
329
- Array.isArray(caught.appliedResults)
330
- ? caught.appliedResults
331
- : []
332
- if (preview.mode === 'create' && appliedResults.length > 0) {
333
- setYaml(preview.yaml)
334
- setMode('apply')
335
- setPreview(null)
336
- setError(
337
- `${message} Radar switched to Apply mode so you can review and continue from the current cluster state.`,
338
- )
339
- return
340
- }
341
362
  setError(message)
363
+ if (recoverPartialCreate(caught, preview.yaml, preview.mode, false)) return
342
364
  if (onPreview) {
343
365
  try {
344
366
  const refreshed = await onPreview({
@@ -357,9 +379,10 @@ export function CreateResourceDialog({
357
379
  }
358
380
  }
359
381
  }
360
- }, [preview, onApply, onPreview, finishApply])
382
+ }, [preview, onApply, onPreview, finishApply, recoverPartialCreate])
361
383
 
362
384
  const dialogTitle = title || 'Create Resource'
385
+ const showModeControls = !lockMode
363
386
  const submitLabel = onPreview ? 'Review' : mode === 'create' ? 'Create' : 'Apply'
364
387
 
365
388
  return (
@@ -409,8 +432,8 @@ export function CreateResourceDialog({
409
432
  </Tooltip>
410
433
  </div>
411
434
 
412
- <div className="min-h-0 flex-1 px-5 py-3">
413
- <div className="mb-2 flex items-center gap-2">
435
+ <div className="flex min-h-0 flex-1 flex-col px-5 py-3">
436
+ <div className="mb-2 flex shrink-0 items-center gap-2">
414
437
  <button
415
438
  type="button"
416
439
  onClick={() => fileInputRef.current?.click()}
@@ -437,7 +460,7 @@ export function CreateResourceDialog({
437
460
  </div>
438
461
 
439
462
  <div
440
- className="relative h-[400px]"
463
+ className="relative h-[400px] min-h-0"
441
464
  onDragEnter={handleDragEnter}
442
465
  onDragOver={handleDragOver}
443
466
  onDragLeave={handleDragLeave}
@@ -446,7 +469,7 @@ export function CreateResourceDialog({
446
469
  <YamlEditor
447
470
  value={yaml}
448
471
  onChange={setYaml}
449
- height="400px"
472
+ height="100%"
450
473
  onValidate={handleValidate}
451
474
  schemaLoader={schemaLoader}
452
475
  />
@@ -470,8 +493,8 @@ export function CreateResourceDialog({
470
493
  </div>
471
494
  </div>
472
495
 
473
- {(error || previewError) && (
474
- <ApplyErrorBanner error={error || previewError || 'Preview failed'} />
496
+ {(error || previewError || partialCreates.length > 0) && (
497
+ <ApplyErrorBanner error={error || previewError || 'Resources were created before the previous attempt stopped.'} partialCreates={partialCreates} strictCreate={lockMode && mode === 'create'} hasPreview={Boolean(onPreview)} />
475
498
  )}
476
499
  {success && (
477
500
  <div className="mx-5 mb-2 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-600 dark:text-emerald-400">
@@ -482,33 +505,35 @@ export function CreateResourceDialog({
482
505
 
483
506
  <div className="flex shrink-0 items-center justify-between border-t border-theme-border px-5 py-3">
484
507
  <div className="flex items-center gap-3">
485
- <Tooltip
486
- content="Apply: create or update (idempotent). Create: fail if exists."
487
- position="bottom"
488
- >
489
- <div
490
- className="flex items-center rounded-md border border-theme-border bg-theme-base p-0.5"
491
- role="radiogroup"
492
- aria-label="Apply mode"
508
+ {showModeControls && (
509
+ <Tooltip
510
+ content="Apply: create or update (idempotent). Create: fail if exists."
511
+ position="bottom"
493
512
  >
494
- {(['apply', 'create'] as const).map((option) => (
495
- <button
496
- type="button"
497
- key={option}
498
- onClick={() => setMode(option)}
499
- role="radio"
500
- aria-checked={mode === option}
501
- className={`rounded px-2.5 py-1 text-xs font-medium capitalize transition-colors ${
502
- mode === option
503
- ? 'bg-theme-elevated text-theme-text-primary shadow-theme-sm'
504
- : 'text-theme-text-tertiary hover:text-theme-text-secondary'
505
- }`}
506
- >
507
- {option}
508
- </button>
509
- ))}
510
- </div>
511
- </Tooltip>
513
+ <div
514
+ className="flex items-center rounded-md border border-theme-border bg-theme-base p-0.5"
515
+ role="radiogroup"
516
+ aria-label="Apply mode"
517
+ >
518
+ {(['apply', 'create'] as const).map((option) => (
519
+ <button
520
+ type="button"
521
+ key={option}
522
+ onClick={() => setMode(option)}
523
+ role="radio"
524
+ aria-checked={mode === option}
525
+ className={`rounded px-2.5 py-1 text-xs font-medium capitalize transition-colors ${
526
+ mode === option
527
+ ? 'bg-theme-elevated text-theme-text-primary shadow-theme-sm'
528
+ : 'text-theme-text-tertiary hover:text-theme-text-secondary'
529
+ }`}
530
+ >
531
+ {option}
532
+ </button>
533
+ ))}
534
+ </div>
535
+ </Tooltip>
536
+ )}
512
537
 
513
538
  {!onPreview && (
514
539
  <Tooltip
@@ -527,26 +552,42 @@ export function CreateResourceDialog({
527
552
  </Tooltip>
528
553
  )}
529
554
 
530
- <Tooltip
531
- content="Override field ownership conflicts. An active controller may reconcile those fields back."
532
- position="bottom"
533
- >
534
- <label
535
- className={`flex items-center gap-1.5 text-xs ${mode === 'apply' ? 'cursor-pointer text-theme-text-secondary' : 'cursor-not-allowed text-theme-text-tertiary'}`}
555
+ {showModeControls && (
556
+ <Tooltip
557
+ content="Override field ownership conflicts. An active controller may reconcile those fields back."
558
+ position="bottom"
536
559
  >
537
- <input
538
- type="checkbox"
539
- checked={mode === 'apply' && force}
540
- disabled={mode !== 'apply'}
541
- onChange={(event) => setForce(event.target.checked)}
542
- className="h-3.5 w-3.5 rounded border-theme-border bg-theme-base"
543
- />
544
- Force
545
- </label>
546
- </Tooltip>
560
+ <label
561
+ className={`flex items-center gap-1.5 text-xs ${mode === 'apply' ? 'cursor-pointer text-theme-text-secondary' : 'cursor-not-allowed text-theme-text-tertiary'}`}
562
+ >
563
+ <input
564
+ type="checkbox"
565
+ checked={mode === 'apply' && force}
566
+ disabled={mode !== 'apply'}
567
+ onChange={(event) => setForce(event.target.checked)}
568
+ className="h-3.5 w-3.5 rounded border-theme-border bg-theme-base"
569
+ />
570
+ Force
571
+ </label>
572
+ </Tooltip>
573
+ )}
547
574
  </div>
548
575
 
549
576
  <div className="flex items-center gap-2">
577
+ {onBack && (
578
+ <button
579
+ type="button"
580
+ onClick={() => {
581
+ cancelScheduledClose()
582
+ retireDeferredImports()
583
+ onBack(yamlRef.current)
584
+ }}
585
+ disabled={pending || importing !== null}
586
+ className="btn-secondary rounded-lg px-3 py-1.5 text-xs"
587
+ >
588
+ {backLabel}
589
+ </button>
590
+ )}
550
591
  <button
551
592
  type="button"
552
593
  onClick={handleClose}
@@ -591,18 +632,23 @@ export function CreateResourceDialog({
591
632
  )
592
633
  }
593
634
 
594
- function ApplyErrorBanner({ error }: { error: string }) {
635
+ function ApplyErrorBanner({ error, partialCreates, strictCreate, hasPreview }: { error: string; partialCreates: ApplyResult[]; strictCreate: boolean; hasPreview: boolean }) {
595
636
  const [expanded, setExpanded] = useState(false)
596
637
  const { panelId, buttonProps } = useDisclosure(expanded)
597
- const parsed = formatApplyError(error)
638
+ const parsed = formatApplyError(error, { allowApply: !strictCreate && partialCreates.length === 0 })
598
639
  const hasFriendly = Boolean(parsed.suggestion)
640
+ const recoveringWithReview = partialCreates.length > 0 && !strictCreate && hasPreview
599
641
  return (
600
- <div className="mx-5 mb-2 rounded-md border border-red-500/30 bg-red-500/10 text-xs">
642
+ <div className="mx-5 mb-2 max-h-[30vh] shrink-0 overflow-y-auto rounded-md border border-red-500/30 bg-red-500/10 text-xs">
601
643
  <div className="flex items-start gap-2 px-3 py-2 text-red-600 dark:text-red-400">
602
644
  <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
603
645
  <div className="min-w-0 flex-1">
604
646
  <span className="font-medium">{parsed.summary}</span>
605
- {parsed.suggestion && (
647
+ {partialCreates.length > 0 && <p className="mt-1">
648
+ Created before it stopped: {partialCreates.map(result => `${result.kind} ${result.namespace ? `${result.namespace}/` : ''}${result.name}`).join(', ')}.
649
+ {' '}{recoveringWithReview ? 'Radar switched to Apply mode so you can review and continue from the current cluster state.' : `${strictCreate ? 'This dialog only creates. ' : ''}Remove the created objects from the manifest before retrying Create.`}
650
+ </p>}
651
+ {parsed.suggestion && !recoveringWithReview && (
606
652
  <p className="mt-1 text-red-500/80 dark:text-red-400/80">{parsed.suggestion}</p>
607
653
  )}
608
654
  </div>
@@ -0,0 +1,80 @@
1
+ // @vitest-environment jsdom
2
+ import { act, useState } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { afterEach, expect, it, vi } from 'vitest'
5
+ import { DetailShell } from './DetailShell'
6
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
7
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
8
+ it('re-measures asynchronously loaded tab badges and keeps the active tab visible', () => {
9
+ const observers = new Set<{ callback: () => void; elements: Element[] }>()
10
+ vi.stubGlobal('ResizeObserver', class {
11
+ record: { callback: () => void; elements: Element[] }
12
+ constructor(callback: () => void) { this.record = { callback, elements: [] }; observers.add(this.record) }
13
+ observe(element: Element) { this.record.elements.push(element) }
14
+ disconnect() { observers.delete(this.record) }
15
+ })
16
+ const width = (el: HTMLElement): number => el.getAttribute('role') === 'tab' ? (el.classList.contains('px-3') ? 100 : 70) + (el.textContent?.includes('mark') ? 40 : 0) : 260
17
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function(this: HTMLElement) { return { width: width(this), height: 20, top: 0, bottom: 20, left: 0, right: width(this), x: 0, y: 0, toJSON: () => ({}) } })
18
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 260)
19
+ vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function(this: HTMLElement) { return [...this.children].reduce((sum, child) => sum + width(child as HTMLElement), 0) })
20
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function(this: HTMLElement) { return width(this) })
21
+ vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function(this: HTMLElement) { return this.getAttribute('role') === 'tab' ? [...this.parentElement!.children].slice(0, [...this.parentElement!.children].indexOf(this)).reduce((sum, child) => sum + width(child as HTMLElement), 0) : 0 })
22
+ let setBadge!: (value: boolean) => void
23
+ function Badge() { const [loaded, set] = useState(false); setBadge = set; return loaded ? <span>mark</span> : null }
24
+ const tabs = [{ id: 'overview', label: 'Overview', icon: <span>icon</span>, badge: <Badge /> }, { id: 'replication', label: 'Replication', icon: <span>icon</span> }, { id: 'yaml', label: 'YAML', icon: <span>icon</span> }]
25
+ const host = document.createElement('div'); const root = createRoot(host)
26
+ act(() => root.render(<DetailShell identity="Cluster" tabs={tabs} activeTab="yaml" onTabChange={() => {}}>content</DetailShell>))
27
+ const strip = host.querySelector<HTMLElement>('[role="tablist"]')!
28
+ expect(host.textContent).not.toContain('icon')
29
+ expect([...observers].some((o) => o.elements.includes(strip.children[0]))).toBe(true)
30
+ act(() => setBadge(true))
31
+ act(() => { for (const observer of [...observers]) observer.callback() })
32
+ // The compact strip grows from 210 to 250 px, still fitting 260 px.
33
+ expect(host.textContent).not.toContain('icon')
34
+ // A second mark makes the compact strip overflow; the active YAML tab remains in view.
35
+ act(() => { strip.children[1].appendChild(document.createTextNode('mark')); for (const observer of [...observers]) observer.callback() })
36
+ const active = strip.querySelector<HTMLElement>('[aria-selected="true"]')!
37
+ expect(strip.scrollLeft).toBeGreaterThan(0)
38
+ expect(active.offsetLeft + active.offsetWidth).toBeLessThanOrEqual(strip.scrollLeft + strip.clientWidth)
39
+ strip.scrollLeft = 0
40
+ strip.dispatchEvent(new Event('scroll'))
41
+ act(() => root.render(<DetailShell identity="Cluster" tabs={[...tabs]} activeTab="yaml" onTabChange={() => {}}>refreshed content</DetailShell>))
42
+ expect(strip.scrollLeft).toBe(0)
43
+ act(() => { for (const observer of [...observers]) observer.callback() })
44
+ expect(strip.scrollLeft).toBe(0)
45
+ act(() => root.unmount())
46
+ })
47
+ it('switches to compact spacing when badge content grows after the initial fit', () => {
48
+ let callback = () => {}; let badgeLoaded = false
49
+ vi.stubGlobal('ResizeObserver', class { constructor(cb: () => void) { callback = cb } observe() {} disconnect() {} })
50
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 220)
51
+ vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(() => badgeLoaded ? 250 : 200)
52
+ const host = document.createElement('div'); const root = createRoot(host)
53
+ const tabs = [{ id: 'overview', label: 'Overview', icon: <span>icon</span> }, { id: 'yaml', label: 'YAML', icon: <span>icon</span>, badge: <span /> }]
54
+ act(() => root.render(<DetailShell identity="Cluster" tabs={tabs} activeTab="yaml" onTabChange={() => {}}>content</DetailShell>))
55
+ expect(host.textContent).toContain('icon')
56
+ act(() => { badgeLoaded = true; callback() })
57
+ expect(host.textContent).not.toContain('icon')
58
+ act(() => root.unmount())
59
+ })
60
+
61
+
62
+ it('lets the actions shrink and wrap while tab focus stays inside the scroll strip', () => {
63
+ const host = document.createElement('div'); const root = createRoot(host)
64
+ act(() => root.render(<DetailShell identity="Deployment" tabs={[{ id: 'overview', label: 'Overview' }, { id: 'yaml', label: 'YAML' }]} activeTab="overview" onTabChange={() => {}} tabStripEnd={<div data-actions>Set image Restart Rollback Port-forward Compare Delete</div>}>content</DetailShell>))
65
+ const actions = host.querySelector('[data-actions]')!.parentElement!
66
+ expect(actions.classList.contains('shrink-0')).toBe(false)
67
+ expect(actions.classList.contains('min-w-0')).toBe(true)
68
+ expect(actions.classList.contains('flex-wrap')).toBe(true)
69
+ const strip = host.querySelector('[role="tablist"]')!
70
+ expect(strip.classList.contains('min-w-0')).toBe(true)
71
+ expect(strip.classList.contains('flex-auto')).toBe(true)
72
+ expect(strip.classList.contains('flex-1')).toBe(false)
73
+ for (const tab of strip.querySelectorAll('[role="tab"]')) {
74
+ expect(tab.classList.contains('focus-visible:outline-none')).toBe(true)
75
+ expect(tab.classList.contains('focus-visible:ring-2')).toBe(true)
76
+ expect(tab.classList.contains('focus-visible:ring-inset')).toBe(true)
77
+ expect(tab.classList.contains('focus-visible:ring-accent')).toBe(true)
78
+ }
79
+ act(() => root.unmount())
80
+ })
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
 
4
4
  // Shared tabbed detail chrome. Hosts provide all data-aware pieces; this owns
@@ -24,6 +24,8 @@ export interface DetailShellProps<TId extends string = string> {
24
24
  nav?: ReactNode
25
25
  identity: ReactNode
26
26
  headerActions?: ReactNode
27
+ /** A line under the identity row, above the tabs — e.g. a status summary that holds on every tab. */
28
+ subheader?: ReactNode
27
29
  scopeControls?: ReactNode
28
30
  tabs: DetailShellTab<TId>[]
29
31
  activeTab: TId
@@ -32,6 +34,11 @@ export interface DetailShellProps<TId extends string = string> {
32
34
  overlay?: ReactNode
33
35
  /** Hide breadcrumb/identity/header actions when a host page already owns that chrome. */
34
36
  compactHeader?: boolean
37
+ /**
38
+ * Let the header actions wrap below the identity, as one group, when both
39
+ * do not fit — for an identity that packs a lot onto its title line.
40
+ */
41
+ wrapHeader?: boolean
35
42
  children: ReactNode
36
43
  }
37
44
 
@@ -40,6 +47,7 @@ export function DetailShell<TId extends string = string>({
40
47
  nav,
41
48
  identity,
42
49
  headerActions,
50
+ subheader,
43
51
  scopeControls,
44
52
  tabs,
45
53
  activeTab,
@@ -47,9 +55,21 @@ export function DetailShell<TId extends string = string>({
47
55
  tabStripEnd,
48
56
  overlay,
49
57
  compactHeader = false,
58
+ wrapHeader = false,
50
59
  children,
51
60
  }: DetailShellProps<TId>) {
52
61
  const visibleTabs = tabs.filter((t) => !t.hidden)
62
+ const { stripRef, compact } = useCompactTabs(visibleTabs.map((t) => `${t.id}:${t.label}:${!!t.badge}`).join('|'))
63
+
64
+ useLayoutEffect(() => {
65
+ const strip = stripRef.current
66
+ const active = strip?.querySelector<HTMLElement>('[aria-selected="true"]')
67
+ if (strip && active) {
68
+ const left = active.offsetLeft - strip.offsetLeft
69
+ if (left < strip.scrollLeft) strip.scrollLeft = left
70
+ else if (left + active.offsetWidth > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = left + active.offsetWidth - strip.clientWidth
71
+ }
72
+ }, [activeTab, compact])
53
73
 
54
74
  return (
55
75
  <div className="flex flex-col h-full w-full bg-theme-base">
@@ -58,27 +78,31 @@ export function DetailShell<TId extends string = string>({
58
78
  {!compactHeader && (
59
79
  <>
60
80
  {breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
61
- <div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
81
+ <div className={clsx('px-6 flex items-start gap-4', wrapHeader && 'flex-wrap gap-y-2', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
62
82
  {nav}
63
- <div className="flex-1 min-w-0">{identity}</div>
64
- {headerActions}
83
+ {/* Wrapping on, the identity's basis is its one-line width, so the
84
+ actions move below whenever the whole title line would not fit
85
+ beside them — never squeezing the name to keep them up. */}
86
+ <div className={clsx('min-w-0', wrapHeader ? 'flex-auto' : 'flex-1')}>{identity}</div>
87
+ {wrapHeader ? <div className="ml-auto flex shrink-0 items-start gap-4">{headerActions}</div> : headerActions}
65
88
  </div>
89
+ {subheader && <div className="-mt-1 px-6 pb-3">{subheader}</div>}
66
90
  </>
67
91
  )}
68
92
 
69
93
  {/* Tabs (left) + scope controls / actions (right) */}
70
94
  <div className={clsx('flex items-center', compactHeader ? 'px-0' : 'border-t border-theme-border px-6')}>
71
- <div className="flex gap-1" role="tablist">
95
+ <div ref={stripRef} className={clsx('flex min-w-0 flex-auto overflow-x-auto', compact ? 'gap-0' : 'gap-1')} role="tablist">
72
96
  {visibleTabs.map((t) => (
73
- <DetailShellTabButton key={t.id} active={activeTab === t.id} onClick={() => onTabChange(t.id)}>
74
- {t.icon}
97
+ <DetailShellTabButton key={t.id} active={activeTab === t.id} compact={compact} onClick={() => onTabChange(t.id)}>
98
+ {!compact && t.icon}
75
99
  {t.label}
76
100
  {t.badge}
77
101
  </DetailShellTabButton>
78
102
  ))}
79
103
  </div>
80
104
  {(scopeControls || tabStripEnd) && (
81
- <div className="ml-auto flex items-center gap-2">
105
+ <div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2 pl-2">
82
106
  {scopeControls}
83
107
  {tabStripEnd}
84
108
  </div>
@@ -94,7 +118,57 @@ export function DetailShell<TId extends string = string>({
94
118
  )
95
119
  }
96
120
 
97
- function DetailShellTabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
121
+ /**
122
+ * Drops the tab icons and tightens spacing only while the full strip does not
123
+ * fit, so a page with many tabs stays on one line at laptop widths and others
124
+ * keep their look. The width the strip needs with icons is measured while it
125
+ * shows them, so toggling cannot oscillate.
126
+ */
127
+ function useCompactTabs(tabsKey: string) {
128
+ const stripRef = useRef<HTMLDivElement>(null)
129
+ const needed = useRef(0)
130
+ const [compact, setCompact] = useState(false)
131
+ useLayoutEffect(() => {
132
+ needed.current = 0
133
+ setCompact(false)
134
+ }, [tabsKey])
135
+ useLayoutEffect(() => {
136
+ const el = stripRef.current
137
+ if (!el || typeof ResizeObserver === 'undefined') return
138
+ const childWidth = () => [...el.children].reduce((sum, child) => sum + child.getBoundingClientRect().width, 0)
139
+ let previousWidth = childWidth()
140
+ let followActive = true
141
+ const onScroll = () => {
142
+ const active = el.querySelector<HTMLElement>('[aria-selected="true"]')
143
+ if (!active) return
144
+ const left = active.offsetLeft - el.offsetLeft
145
+ followActive = left >= el.scrollLeft && left + active.offsetWidth <= el.scrollLeft + el.clientWidth
146
+ }
147
+ const measure = () => {
148
+ const width = childWidth()
149
+ if (compact) needed.current += width - previousWidth
150
+ previousWidth = width
151
+ if (!compact) needed.current = el.scrollWidth
152
+ const next = needed.current > el.clientWidth + 1
153
+ setCompact((prev) => (prev === next ? prev : next))
154
+ const active = el.querySelector<HTMLElement>('[aria-selected="true"]')
155
+ if (active && followActive) {
156
+ const left = active.offsetLeft - el.offsetLeft
157
+ if (left < el.scrollLeft) el.scrollLeft = left
158
+ else if (left + active.offsetWidth > el.scrollLeft + el.clientWidth) el.scrollLeft = left + active.offsetWidth - el.clientWidth
159
+ }
160
+ }
161
+ measure()
162
+ const ro = new ResizeObserver(measure)
163
+ ro.observe(el)
164
+ for (const child of el.children) ro.observe(child)
165
+ el.addEventListener('scroll', onScroll)
166
+ return () => { ro.disconnect(); el.removeEventListener('scroll', onScroll) }
167
+ }, [compact, tabsKey])
168
+ return { stripRef, compact }
169
+ }
170
+
171
+ function DetailShellTabButton({ active, compact, onClick, children }: { active: boolean; compact: boolean; onClick: () => void; children: ReactNode }) {
98
172
  return (
99
173
  <button
100
174
  type="button"
@@ -102,7 +176,8 @@ function DetailShellTabButton({ active, onClick, children }: { active: boolean;
102
176
  aria-selected={active}
103
177
  onClick={onClick}
104
178
  className={clsx(
105
- 'flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors',
179
+ 'flex shrink-0 items-center gap-1.5 whitespace-nowrap py-2 text-sm font-medium border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent',
180
+ compact ? 'px-2' : 'px-3',
106
181
  active
107
182
  ? 'text-theme-text-primary border-skyhook-500'
108
183
  : 'text-theme-text-secondary border-transparent hover:text-theme-text-primary hover:border-theme-border-light',
@@ -266,3 +266,10 @@ it("drops a refresh from an apply made before going back to a newer review", asy
266
266
  expect.objectContaining({ reviewedResourceVersion: "3" }),
267
267
  );
268
268
  });
269
+
270
+ it("uses the accent token for a generic ConfigMap Edit control", () => {
271
+ render({ onSave: async () => {}, onPreview: async () => reviewed("1") });
272
+ const edit = [...host.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Edit")!;
273
+ expect(edit.classList.contains("text-accent-text")).toBe(true);
274
+ expect(edit.className).not.toContain("text-blue-");
275
+ });