@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.
- package/package.json +4 -4
- package/src/components/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +39 -7
- package/src/components/applications/ApplicationsView.tsx +18 -3
- package/src/components/applications/ReadyBar.tsx +3 -2
- package/src/components/applications/applications-view.test.tsx +91 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +40 -31
- package/src/components/issues/ResourceIssuesSection.tsx +3 -2
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +123 -4
- package/src/components/issues/issues.test.ts +221 -10
- package/src/components/issues/types.ts +31 -5
- package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
- package/src/components/resources/ResourcesView.tsx +966 -98
- package/src/components/resources/column-settings-key.test.ts +98 -0
- package/src/components/resources/column-visibility-merge.test.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +236 -0
- package/src/components/resources/gateway-policy-status.test.ts +291 -0
- package/src/components/resources/gateway-policy-status.ts +237 -0
- package/src/components/resources/generic-status.test.ts +199 -0
- package/src/components/resources/generic-status.ts +171 -0
- package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
- package/src/components/resources/index.ts +9 -1
- package/src/components/resources/istio-gateway-filter.test.ts +97 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
- package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
- package/src/components/resources/printer-columns.test.ts +298 -0
- package/src/components/resources/printer-columns.ts +249 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
- package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -5
- package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
- package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
- package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
- package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
- package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
- package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
- package/src/components/resources/renderers/kai-cells.tsx +67 -0
- package/src/components/resources/renderers/kaito-cells.tsx +62 -0
- package/src/components/resources/renderers/kserve-cells.tsx +145 -0
- package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
- package/src/components/resources/renderers/kueue-cells.tsx +176 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
- package/src/components/resources/renderers/nim-cells.tsx +81 -0
- package/src/components/resources/renderers/ray-cells.tsx +124 -0
- package/src/components/resources/renderers/volcano-cells.tsx +140 -0
- package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
- package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
- package/src/components/resources/resource-utils-cnpg.ts +132 -7
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +236 -0
- package/src/components/resources/resource-utils-hpa.test.ts +4 -0
- package/src/components/resources/resource-utils-hpa.ts +6 -2
- package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
- package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
- package/src/components/resources/resource-utils-kai.ts +105 -0
- package/src/components/resources/resource-utils-kaito.ts +114 -0
- package/src/components/resources/resource-utils-kserve.ts +186 -0
- package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
- package/src/components/resources/resource-utils-kueue.ts +200 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
- package/src/components/resources/resource-utils-kyverno.ts +51 -2
- package/src/components/resources/resource-utils-nim.ts +109 -0
- package/src/components/resources/resource-utils-ray.ts +214 -0
- package/src/components/resources/resource-utils-rollout.test.ts +183 -0
- package/src/components/resources/resource-utils-velero.test.ts +30 -0
- package/src/components/resources/resource-utils-velero.ts +32 -0
- package/src/components/resources/resource-utils-volcano.ts +176 -0
- package/src/components/resources/resource-utils.ts +156 -47
- package/src/components/resources/resources-column-filter.test.ts +27 -1
- package/src/components/resources/workload-rollout-golden.test.ts +55 -0
- package/src/components/shared/CreateResourceDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.tsx +4 -2
- package/src/components/shared/ResourceActionsBar.tsx +170 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
- package/src/components/shared/SetImageDialog.test.ts +129 -0
- package/src/components/shared/SetImageDialog.tsx +577 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/shared/revision-role-badges.test.ts +56 -4
- package/src/components/timeline/TimelineList.tsx +19 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
- package/src/components/trace/ReachabilityView.tsx +1 -1
- package/src/components/ui/SelectMenu.tsx +10 -1
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +144 -81
- package/src/hooks/useProgressiveRefresh.ts +37 -0
- package/src/source-hygiene.test.ts +28 -0
- package/src/theme/tailwind-theme.css +4 -0
- package/src/types/core.ts +55 -0
- package/src/utils/api-resources.test.ts +40 -1
- package/src/utils/api-resources.ts +11 -1
- package/src/utils/applications.ts +50 -0
- package/src/utils/bulk-workload-actions.test.ts +20 -0
- package/src/utils/bulk-workload-actions.ts +15 -0
- package/src/utils/navigation.test.ts +31 -1
- package/src/utils/navigation.ts +21 -2
- 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
|
|
96
|
-
//
|
|
97
|
-
it('
|
|
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.
|
|
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
|
+
})
|