@skyhook-io/k8s-ui 1.13.3 → 1.14.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 (119) hide show
  1. package/package.json +4 -4
  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 +39 -7
  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 +4 -1
  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 +966 -98
  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/kyverno-legacy-action.test.ts +100 -1
  28. package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
  29. package/src/components/resources/printer-columns.test.ts +298 -0
  30. package/src/components/resources/printer-columns.ts +249 -0
  31. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
  32. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
  34. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
  35. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  36. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  37. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  38. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  39. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  40. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
  41. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  42. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  43. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  44. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
  45. package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
  46. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  47. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  48. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
  49. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
  50. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
  51. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
  52. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  53. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  54. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  55. package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
  56. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  57. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  58. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  59. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  60. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  61. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  62. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  63. package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
  64. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  65. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  66. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  67. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  68. package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
  69. package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
  70. package/src/components/resources/resource-utils-cnpg.ts +132 -7
  71. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +236 -0
  72. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  73. package/src/components/resources/resource-utils-hpa.ts +6 -2
  74. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  75. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  76. package/src/components/resources/resource-utils-kai.ts +105 -0
  77. package/src/components/resources/resource-utils-kaito.ts +114 -0
  78. package/src/components/resources/resource-utils-kserve.ts +186 -0
  79. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  80. package/src/components/resources/resource-utils-kueue.ts +200 -0
  81. package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
  82. package/src/components/resources/resource-utils-kyverno.ts +51 -2
  83. package/src/components/resources/resource-utils-nim.ts +109 -0
  84. package/src/components/resources/resource-utils-ray.ts +214 -0
  85. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  86. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  87. package/src/components/resources/resource-utils-velero.ts +32 -0
  88. package/src/components/resources/resource-utils-volcano.ts +176 -0
  89. package/src/components/resources/resource-utils.ts +156 -47
  90. package/src/components/resources/resources-column-filter.test.ts +27 -1
  91. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  92. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  93. package/src/components/shared/EditableYamlView.tsx +4 -2
  94. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  95. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  96. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  97. package/src/components/shared/SetImageDialog.test.ts +129 -0
  98. package/src/components/shared/SetImageDialog.tsx +577 -0
  99. package/src/components/shared/index.ts +1 -0
  100. package/src/components/shared/revision-role-badges.test.ts +56 -4
  101. package/src/components/timeline/TimelineList.tsx +19 -3
  102. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  103. package/src/components/trace/ReachabilityView.tsx +1 -1
  104. package/src/components/ui/SelectMenu.tsx +10 -1
  105. package/src/components/ui/drawer-components.tsx +12 -0
  106. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  107. package/src/components/workload/WorkloadView.tsx +144 -81
  108. package/src/hooks/useProgressiveRefresh.ts +37 -0
  109. package/src/source-hygiene.test.ts +28 -0
  110. package/src/theme/tailwind-theme.css +4 -0
  111. package/src/types/core.ts +55 -0
  112. package/src/utils/api-resources.test.ts +40 -1
  113. package/src/utils/api-resources.ts +11 -1
  114. package/src/utils/applications.ts +50 -0
  115. package/src/utils/bulk-workload-actions.test.ts +20 -0
  116. package/src/utils/bulk-workload-actions.ts +15 -0
  117. package/src/utils/navigation.test.ts +31 -1
  118. package/src/utils/navigation.ts +21 -2
  119. package/src/utils/workload-rollout.ts +292 -0
@@ -0,0 +1,577 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import {
3
+ AlertTriangle,
4
+ ExternalLink,
5
+ Loader2,
6
+ RefreshCw,
7
+ X,
8
+ } from 'lucide-react'
9
+ import { clsx } from 'clsx'
10
+
11
+ import type {
12
+ WorkloadContainerImage,
13
+ WorkloadImageInventory,
14
+ WorkloadImageUpdate,
15
+ WorkloadUpdateBehavior,
16
+ } from '../../types/core'
17
+ import { DialogPortal } from '../ui/DialogPortal'
18
+ import { Badge } from '../ui/Badge'
19
+
20
+ export interface ManagedImageSource {
21
+ type: 'GitOps' | 'Helm'
22
+ label: string
23
+ onOpen?: () => void
24
+ }
25
+
26
+ export interface SetImageDialogProps {
27
+ open: boolean
28
+ workloadLabel: string
29
+ workloadName: string
30
+ workloadResource: string
31
+ managedSources?: ManagedImageSource[]
32
+ pending?: boolean
33
+ onClose: () => void
34
+ onLoad: () => Promise<WorkloadImageInventory>
35
+ onConfirm: (updates: WorkloadImageUpdate[]) => Promise<unknown>
36
+ }
37
+
38
+ function containerKey(
39
+ container: Pick<WorkloadContainerImage, 'type' | 'name'>,
40
+ ): string {
41
+ return `${container.type}\u0000${container.name}`
42
+ }
43
+
44
+ export function changedImageUpdates(
45
+ containers: WorkloadContainerImage[],
46
+ drafts: Record<string, string>,
47
+ ): WorkloadImageUpdate[] {
48
+ return containers.flatMap((container) => {
49
+ const image = (drafts[containerKey(container)] ?? container.image).trim()
50
+ if (!image || image === container.image) return []
51
+ return [
52
+ {
53
+ type: container.type,
54
+ name: container.name,
55
+ previousImage: container.image,
56
+ image,
57
+ },
58
+ ]
59
+ })
60
+ }
61
+
62
+ export function reconcileRefreshedImageDrafts(
63
+ previousContainers: WorkloadContainerImage[],
64
+ nextContainers: WorkloadContainerImage[],
65
+ drafts: Record<string, string>,
66
+ ): { drafts: Record<string, string>; changedCurrentKeys: string[] } {
67
+ const previousByKey = new Map(
68
+ previousContainers.map((container) => [containerKey(container), container]),
69
+ )
70
+ const nextDrafts: Record<string, string> = {}
71
+ const changedCurrentKeys: string[] = []
72
+
73
+ for (const container of nextContainers) {
74
+ const key = containerKey(container)
75
+ const previous = previousByKey.get(key)
76
+ const priorDraft = drafts[key] ?? previous?.image ?? container.image
77
+ const wasEdited = Boolean(previous) && priorDraft.trim() !== previous?.image
78
+ nextDrafts[key] = wasEdited ? priorDraft : container.image
79
+ if (previous && previous.image !== container.image)
80
+ changedCurrentKeys.push(key)
81
+ }
82
+
83
+ return { drafts: nextDrafts, changedCurrentKeys }
84
+ }
85
+
86
+ export function canSubmitImageUpdates({
87
+ updateCount,
88
+ hasEmptyImage,
89
+ managed,
90
+ ownershipResolved,
91
+ acknowledged,
92
+ busy,
93
+ loadFailed = false,
94
+ }: {
95
+ updateCount: number
96
+ hasEmptyImage: boolean
97
+ managed: boolean
98
+ ownershipResolved: boolean
99
+ acknowledged: boolean
100
+ busy: boolean
101
+ loadFailed?: boolean
102
+ }): boolean {
103
+ return (
104
+ updateCount > 0 &&
105
+ !hasEmptyImage &&
106
+ ownershipResolved &&
107
+ (!managed || acknowledged) &&
108
+ !busy &&
109
+ !loadFailed
110
+ )
111
+ }
112
+
113
+ export function describeImageUpdateBehavior(
114
+ behavior: WorkloadUpdateBehavior,
115
+ ): string {
116
+ switch (behavior.type) {
117
+ case 'paused':
118
+ return 'This controller is paused. Radar will save the template change, but no rollout starts until it is resumed.'
119
+ case 'recreate':
120
+ return 'This Deployment uses Recreate. Existing Pods are stopped before replacement Pods start.'
121
+ case 'onDelete':
122
+ return 'This controller uses OnDelete. Existing Pods keep running until they are deleted.'
123
+ case 'partitioned':
124
+ return `Only StatefulSet Pods with ordinal ${behavior.partition ?? 0} or higher update automatically.`
125
+ case 'canary':
126
+ return behavior.gated
127
+ ? 'Saving starts a new Argo canary using the Rollout’s configured steps.'
128
+ : 'This Rollout has no canary steps. Saving rolls the new revision out to 100% without a pause.'
129
+ case 'blueGreen':
130
+ return behavior.autoPromote
131
+ ? 'Saving creates a new Argo preview revision, which Argo promotes to the active Service automatically once it is ready.'
132
+ : 'Saving creates a new Argo preview revision. Promotion remains a separate action.'
133
+ default:
134
+ return 'Saving changes the Pod template and starts the controller’s rolling update.'
135
+ }
136
+ }
137
+
138
+ export function SetImageDialog({
139
+ open,
140
+ workloadLabel,
141
+ workloadName,
142
+ workloadResource,
143
+ managedSources,
144
+ pending = false,
145
+ onClose,
146
+ onLoad,
147
+ onConfirm,
148
+ }: SetImageDialogProps) {
149
+ const [inventory, setInventory] = useState<WorkloadImageInventory | null>(
150
+ null,
151
+ )
152
+ const [drafts, setDrafts] = useState<Record<string, string>>({})
153
+ const [loading, setLoading] = useState(false)
154
+ const [submitting, setSubmitting] = useState(false)
155
+ const [loadError, setLoadError] = useState<string | null>(null)
156
+ const [submitError, setSubmitError] = useState<string | null>(null)
157
+ const [acknowledged, setAcknowledged] = useState(false)
158
+ const [changedCurrentKeys, setChangedCurrentKeys] = useState<string[]>([])
159
+ const onLoadRef = useRef(onLoad)
160
+ const loadRequestRef = useRef(0)
161
+
162
+ useEffect(() => {
163
+ onLoadRef.current = onLoad
164
+ }, [onLoad])
165
+
166
+ const loadInventory = async (preserveDrafts = false): Promise<boolean> => {
167
+ const request = ++loadRequestRef.current
168
+ const previousInventory = inventory
169
+ const previousDrafts = drafts
170
+ setLoading(true)
171
+ setLoadError(null)
172
+ try {
173
+ const next = await onLoadRef.current()
174
+ if (request !== loadRequestRef.current) return false
175
+ setInventory(next)
176
+ if (preserveDrafts && previousInventory) {
177
+ const reconciled = reconcileRefreshedImageDrafts(
178
+ previousInventory.containers,
179
+ next.containers,
180
+ previousDrafts,
181
+ )
182
+ setDrafts(reconciled.drafts)
183
+ setChangedCurrentKeys(reconciled.changedCurrentKeys)
184
+ } else {
185
+ setDrafts(
186
+ Object.fromEntries(
187
+ next.containers.map((container) => [
188
+ containerKey(container),
189
+ container.image,
190
+ ]),
191
+ ),
192
+ )
193
+ setChangedCurrentKeys([])
194
+ }
195
+ return true
196
+ } catch (error) {
197
+ if (request !== loadRequestRef.current) return false
198
+ setLoadError(
199
+ error instanceof Error
200
+ ? error.message
201
+ : 'Failed to load container images',
202
+ )
203
+ return false
204
+ } finally {
205
+ if (request === loadRequestRef.current) setLoading(false)
206
+ }
207
+ }
208
+
209
+ useEffect(() => {
210
+ if (!open) {
211
+ loadRequestRef.current += 1
212
+ return
213
+ }
214
+ setInventory(null)
215
+ setDrafts({})
216
+ setChangedCurrentKeys([])
217
+ setSubmitError(null)
218
+ setAcknowledged(false)
219
+ void loadInventory()
220
+ }, [open, workloadLabel, workloadName, workloadResource])
221
+
222
+ const updates = useMemo(
223
+ () => changedImageUpdates(inventory?.containers ?? [], drafts),
224
+ [drafts, inventory?.containers],
225
+ )
226
+ const hasEmptyImage =
227
+ inventory?.containers.some(
228
+ (container) => !(drafts[containerKey(container)] ?? '').trim(),
229
+ ) ?? false
230
+ const busy = pending || submitting
231
+ const ownershipResolved = managedSources !== undefined
232
+ const canSubmit = canSubmitImageUpdates({
233
+ updateCount: updates.length,
234
+ hasEmptyImage,
235
+ managed: (managedSources?.length ?? 0) > 0,
236
+ ownershipResolved,
237
+ acknowledged,
238
+ busy,
239
+ loadFailed: Boolean(loadError),
240
+ })
241
+
242
+ const handleSubmit = async () => {
243
+ if (!canSubmit) return
244
+ setSubmitting(true)
245
+ setSubmitError(null)
246
+ try {
247
+ await onConfirm(updates)
248
+ onClose()
249
+ } catch (error) {
250
+ const message =
251
+ error instanceof Error
252
+ ? error.message
253
+ : 'Failed to update container images'
254
+ if (
255
+ typeof error === 'object' &&
256
+ error !== null &&
257
+ 'status' in error &&
258
+ error.status === 409
259
+ ) {
260
+ const refreshed = await loadInventory(true)
261
+ const sentence = /[.!?]$/.test(message.trim())
262
+ ? message.trim()
263
+ : `${message.trim()}.`
264
+ setSubmitError(
265
+ refreshed
266
+ ? `${sentence} Current images were refreshed; your proposed images were preserved.`
267
+ : `${sentence} Reload the current images before trying again.`,
268
+ )
269
+ } else {
270
+ setSubmitError(message)
271
+ }
272
+ } finally {
273
+ setSubmitting(false)
274
+ }
275
+ }
276
+
277
+ const regular =
278
+ inventory?.containers.filter(
279
+ (container) => container.type === 'container',
280
+ ) ?? []
281
+ const init =
282
+ inventory?.containers.filter(
283
+ (container) => container.type === 'initContainer',
284
+ ) ?? []
285
+ const targetDiffers =
286
+ inventory &&
287
+ (inventory.target.name !== workloadName ||
288
+ inventory.target.resource !== workloadResource.toLowerCase())
289
+
290
+ return (
291
+ <DialogPortal
292
+ open={open}
293
+ onClose={busy ? () => {} : onClose}
294
+ closable={!busy}
295
+ className="w-[760px] max-w-[calc(100vw-2rem)] max-h-[85vh] flex flex-col"
296
+ >
297
+ <div className="flex items-start justify-between gap-4 border-b border-theme-border px-5 py-4">
298
+ <div className="min-w-0">
299
+ <h2 className="text-sm font-semibold text-theme-text-primary">
300
+ Update container images
301
+ </h2>
302
+ <p className="mt-0.5 truncate text-xs text-theme-text-tertiary">
303
+ {workloadLabel}
304
+ </p>
305
+ </div>
306
+ <button
307
+ type="button"
308
+ onClick={onClose}
309
+ disabled={busy}
310
+ aria-label="Close"
311
+ className="rounded p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary disabled:opacity-50"
312
+ >
313
+ <X className="h-4 w-4" />
314
+ </button>
315
+ </div>
316
+
317
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
318
+ {loading && !inventory ? (
319
+ <div className="flex items-center justify-center gap-2 py-12 text-sm text-theme-text-secondary">
320
+ <Loader2 className="h-4 w-4 animate-spin" />
321
+ Loading current images…
322
+ </div>
323
+ ) : loadError && !inventory ? (
324
+ <div className="rounded-lg border border-theme-border bg-theme-base px-4 py-6 text-center">
325
+ <p className="text-sm text-theme-text-primary">
326
+ Couldn’t load container images
327
+ </p>
328
+ <p className="mt-1 text-xs text-theme-text-tertiary">{loadError}</p>
329
+ <button
330
+ type="button"
331
+ onClick={() => void loadInventory()}
332
+ className="mt-3 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs btn-brand-muted"
333
+ >
334
+ <RefreshCw className="h-3.5 w-3.5" />
335
+ Retry
336
+ </button>
337
+ </div>
338
+ ) : inventory ? (
339
+ <div className="space-y-4">
340
+ {loadError && (
341
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-xs">
342
+ <p className="font-medium text-theme-text-primary">Refresh required</p>
343
+ <p className="mt-1 text-theme-text-secondary">
344
+ Radar couldn’t verify the latest images and ownership. {loadError}
345
+ </p>
346
+ <button
347
+ type="button"
348
+ onClick={() => void loadInventory(true)}
349
+ className="mt-2 inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium btn-brand-muted"
350
+ >
351
+ <RefreshCw className="h-3.5 w-3.5" />
352
+ Retry refresh
353
+ </button>
354
+ </div>
355
+ )}
356
+ {!ownershipResolved && (
357
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-xs">
358
+ <p className="font-medium text-theme-text-primary">Management ownership unavailable</p>
359
+ <p className="mt-1 text-theme-text-secondary">
360
+ The host must verify GitOps and Helm ownership before Radar can safely update a live image.
361
+ </p>
362
+ </div>
363
+ )}
364
+ {targetDiffers && (
365
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary">
366
+ This Rollout reads its Pod template from{' '}
367
+ <span className="font-mono text-theme-text-primary">
368
+ {inventory.target.kind} {inventory.target.namespace}/
369
+ {inventory.target.name}
370
+ </span>
371
+ . That referenced object will be updated.
372
+ </div>
373
+ )}
374
+
375
+ <div className="rounded-md border border-skyhook-500/30 bg-skyhook-500/10 px-3 py-2 text-xs text-theme-text-secondary">
376
+ {describeImageUpdateBehavior(inventory.behavior)}
377
+ </div>
378
+
379
+ {managedSources && managedSources.length > 0 && (
380
+ <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-3">
381
+ <div className="flex items-start gap-2">
382
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
383
+ <div className="min-w-0 flex-1">
384
+ <p className="text-xs font-medium text-amber-800 dark:text-amber-300">
385
+ The declared source may overwrite this live change
386
+ </p>
387
+ <p className="mt-1 text-xs text-theme-text-secondary">
388
+ Managed by{' '}
389
+ {managedSources
390
+ .map((source) => `${source.type} ${source.label}`)
391
+ .join(' and ')}
392
+ . Update the declared source for a durable change; use
393
+ this action only as a break-glass operation.
394
+ </p>
395
+ <div className="mt-2 flex flex-wrap gap-2">
396
+ {managedSources
397
+ .filter((source) => source.onOpen)
398
+ .map((source) => (
399
+ <button
400
+ key={`${source.type}-${source.label}`}
401
+ type="button"
402
+ onClick={source.onOpen}
403
+ className="inline-flex items-center gap-1 text-xs font-medium text-skyhook-600 hover:underline dark:text-skyhook-400"
404
+ >
405
+ {source.type === 'GitOps'
406
+ ? 'View GitOps owner'
407
+ : 'View Helm release'}
408
+ <ExternalLink className="h-3 w-3" />
409
+ </button>
410
+ ))}
411
+ </div>
412
+ <label className="mt-3 flex cursor-pointer items-start gap-2 text-xs text-theme-text-primary">
413
+ <input
414
+ type="checkbox"
415
+ checked={acknowledged}
416
+ onChange={(event) =>
417
+ setAcknowledged(event.target.checked)
418
+ }
419
+ disabled={busy}
420
+ className="mt-0.5 h-3.5 w-3.5 accent-skyhook-500"
421
+ />
422
+ <span>
423
+ I understand the declared source may overwrite this live
424
+ change.
425
+ </span>
426
+ </label>
427
+ </div>
428
+ </div>
429
+ </div>
430
+ )}
431
+
432
+ <ContainerImageGroup
433
+ title="Containers"
434
+ containers={regular}
435
+ drafts={drafts}
436
+ changedCurrentKeys={changedCurrentKeys}
437
+ disabled={busy}
438
+ onChange={(container, image) =>
439
+ setDrafts((current) => ({
440
+ ...current,
441
+ [containerKey(container)]: image,
442
+ }))
443
+ }
444
+ />
445
+ {init.length > 0 && (
446
+ <ContainerImageGroup
447
+ title="Init containers"
448
+ containers={init}
449
+ drafts={drafts}
450
+ changedCurrentKeys={changedCurrentKeys}
451
+ disabled={busy}
452
+ onChange={(container, image) =>
453
+ setDrafts((current) => ({
454
+ ...current,
455
+ [containerKey(container)]: image,
456
+ }))
457
+ }
458
+ />
459
+ )}
460
+ {submitError && (
461
+ <div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-400">
462
+ {submitError}
463
+ </div>
464
+ )}
465
+ </div>
466
+ ) : null}
467
+ </div>
468
+
469
+ <div className="flex items-center justify-between gap-3 border-t border-theme-border bg-theme-base px-5 py-3">
470
+ <p className="text-xs text-theme-text-tertiary">
471
+ {hasEmptyImage
472
+ ? 'Enter an image for every container'
473
+ : updates.length
474
+ ? `${updates.length} image${updates.length === 1 ? '' : 's'} changed`
475
+ : 'No changes'}
476
+ </p>
477
+ <div className="flex items-center gap-2">
478
+ <button
479
+ type="button"
480
+ onClick={onClose}
481
+ disabled={busy}
482
+ className="rounded-md border border-theme-border bg-theme-surface px-3 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:opacity-50"
483
+ >
484
+ Cancel
485
+ </button>
486
+ <button
487
+ type="button"
488
+ onClick={() => void handleSubmit()}
489
+ disabled={!canSubmit}
490
+ className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs btn-brand disabled:cursor-not-allowed disabled:opacity-50"
491
+ >
492
+ {busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
493
+ {busy
494
+ ? 'Updating…'
495
+ : `Update ${updates.length || ''} image${updates.length === 1 ? '' : 's'}`.replace(
496
+ ' ',
497
+ ' ',
498
+ )}
499
+ </button>
500
+ </div>
501
+ </div>
502
+ </DialogPortal>
503
+ )
504
+ }
505
+
506
+ function ContainerImageGroup({
507
+ title,
508
+ containers,
509
+ drafts,
510
+ changedCurrentKeys,
511
+ disabled,
512
+ onChange,
513
+ }: {
514
+ title: string
515
+ containers: WorkloadContainerImage[]
516
+ drafts: Record<string, string>
517
+ changedCurrentKeys: string[]
518
+ disabled: boolean
519
+ onChange: (container: WorkloadContainerImage, image: string) => void
520
+ }) {
521
+ return (
522
+ <section>
523
+ <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
524
+ {title}
525
+ </h3>
526
+ <div className="overflow-hidden rounded-lg border border-theme-border">
527
+ <div className="hidden grid-cols-[minmax(100px,0.6fr)_minmax(0,1fr)_minmax(0,1.2fr)] gap-3 border-b border-theme-border bg-theme-base px-3 py-2 text-[11px] font-medium text-theme-text-tertiary sm:grid">
528
+ <span>Name</span>
529
+ <span>Current image</span>
530
+ <span>New image</span>
531
+ </div>
532
+ <div className="divide-y divide-theme-border">
533
+ {containers.map((container) => {
534
+ const key = containerKey(container)
535
+ const empty = !(drafts[key] ?? container.image).trim()
536
+ const currentChanged = changedCurrentKeys.includes(key)
537
+ return (
538
+ <div
539
+ key={key}
540
+ className="grid gap-2 px-3 py-3 sm:grid-cols-[minmax(100px,0.6fr)_minmax(0,1fr)_minmax(0,1.2fr)] sm:items-center sm:gap-3"
541
+ >
542
+ <div className="min-w-0 font-mono text-xs font-medium text-theme-text-primary">
543
+ {container.name}
544
+ </div>
545
+ <div className="min-w-0">
546
+ <div className="break-all font-mono text-[11px] text-theme-text-tertiary">
547
+ {container.image}
548
+ </div>
549
+ {currentChanged && (
550
+ <Badge severity="warning" size="sm" className="mt-1">
551
+ Changed in cluster
552
+ </Badge>
553
+ )}
554
+ </div>
555
+ <input
556
+ type="text"
557
+ value={drafts[key] ?? container.image}
558
+ onChange={(event) => onChange(container, event.target.value)}
559
+ disabled={disabled}
560
+ aria-label={`New image for ${container.name}`}
561
+ aria-invalid={empty || undefined}
562
+ spellCheck={false}
563
+ className={clsx(
564
+ 'min-w-0 rounded-md border bg-theme-base px-2.5 py-2 font-mono text-xs text-theme-text-primary outline-none focus:ring-1 disabled:opacity-50',
565
+ empty
566
+ ? 'border-red-500/60 focus:border-red-500 focus:ring-red-500'
567
+ : 'border-theme-border focus:border-skyhook-500 focus:ring-skyhook-500',
568
+ )}
569
+ />
570
+ </div>
571
+ )
572
+ })}
573
+ </div>
574
+ </div>
575
+ </section>
576
+ )
577
+ }
@@ -1,6 +1,7 @@
1
1
  export { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
2
2
  export { EditableYamlView, SaveSuccessAnimation } from './EditableYamlView'
3
3
  export { ResourceActionsBar, RevisionHistoryDialog, isRolloutKind } from './ResourceActionsBar'
4
+ export { SetImageDialog, type ManagedImageSource, type SetImageDialogProps } from './SetImageDialog'
4
5
  export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
5
6
  export { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from './ManagedByChip'
6
7
  export { DetailShell, type DetailShellProps, type DetailShellTab } from './DetailShell'
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  completePromoteAfterRollback,
4
4
  offersPromoteAfterRollback,
5
+ promoteFailureGuidance,
5
6
  revisionRoleBadges,
6
7
  } from './ResourceActionsBar'
7
8
  import type { WorkloadRevision } from '../../types/core'
@@ -92,19 +93,46 @@ describe('completePromoteAfterRollback', () => {
92
93
  expect(pending).toEqual([true, false])
93
94
  })
94
95
 
95
- // A rejected promote must not stop the dialog from closing — the rollback itself
96
- // already succeeded, and the failure is reported by the mutation's toast.
97
- it('swallows a promote-full rejection and clears the pending flag', async () => {
96
+ // A rejected promote leaves the Rollout replaying the canary steps the operator asked
97
+ // to skip, so the caller has to be able to tell that half of the pair did not land.
98
+ it('reports a promote-full rejection and clears the pending flag', async () => {
98
99
  const pending: boolean[] = []
99
100
  await expect(
100
101
  completePromoteAfterRollback(
101
102
  () => Promise.reject(new Error('403 forbidden')),
102
103
  (p) => pending.push(p),
103
104
  ),
104
- ).resolves.toBeUndefined()
105
+ ).resolves.toEqual({
106
+ promoted: false,
107
+ error: { message: '403 forbidden', controllerLagging: false },
108
+ })
105
109
  expect(pending).toEqual([true, false])
106
110
  })
107
111
 
112
+ // Keyed on the server's error code, not the status: a lost cluster connection answers
113
+ // 503 on this route too, and offering "retry, it clears in a few seconds" for that
114
+ // would invent a cause.
115
+ it('marks only the controller-lag code as recoverable', async () => {
116
+ const lagging = Object.assign(new Error('controller has not reconciled'), {
117
+ status: 503,
118
+ data: { error_code: 'controller_not_caught_up' },
119
+ })
120
+ await expect(
121
+ completePromoteAfterRollback(() => Promise.reject(lagging), () => {}),
122
+ ).resolves.toEqual({
123
+ promoted: false,
124
+ error: { message: 'controller has not reconciled', controllerLagging: true },
125
+ })
126
+
127
+ const disconnected = Object.assign(new Error('cluster client not available'), { status: 503 })
128
+ await expect(
129
+ completePromoteAfterRollback(() => Promise.reject(disconnected), () => {}),
130
+ ).resolves.toEqual({
131
+ promoted: false,
132
+ error: { message: 'cluster client not available', controllerLagging: false },
133
+ })
134
+ })
135
+
108
136
  it('supports a void-returning callback', async () => {
109
137
  const pending: boolean[] = []
110
138
  let called = false
@@ -118,3 +146,27 @@ describe('completePromoteAfterRollback', () => {
118
146
  expect(pending).toEqual([true, false])
119
147
  })
120
148
  })
149
+
150
+ describe('promoteFailureGuidance', () => {
151
+ // The status alone cannot carry this: a lost cluster connection answers 503 on the
152
+ // same route, and telling the operator to retry that would be a fabricated cause.
153
+ it('offers a retry only when the controller was lagging', () => {
154
+ const lagging = promoteFailureGuidance({ message: 'x', controllerLagging: true })
155
+ expect(lagging.canRetry).toBe(true)
156
+ expect(lagging.body).toContain('had not caught up')
157
+
158
+ const other = promoteFailureGuidance({ message: 'forbidden', controllerLagging: false })
159
+ expect(other.canRetry).toBe(false)
160
+ expect(other.body).not.toContain('had not caught up')
161
+ })
162
+
163
+ // A canary pause is the usual reason to want promote-full, and it will not clear on
164
+ // its own — "it is still running" would read as "wait" when the answer is "act".
165
+ it('warns that a manual pause will not resolve itself', () => {
166
+ for (const controllerLagging of [true, false]) {
167
+ const { body } = promoteFailureGuidance({ message: 'x', controllerLagging })
168
+ expect(body).toContain('manual pause')
169
+ expect(body).not.toContain('progressing normally')
170
+ }
171
+ })
172
+ })