@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
|
@@ -22,10 +22,15 @@ import { classifyDiffLine } from './UnifiedDiff'
|
|
|
22
22
|
import { Tooltip } from '../ui/Tooltip'
|
|
23
23
|
import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDeleteConfirmDialog'
|
|
24
24
|
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
25
|
+
import { AlertBanner } from '../ui/drawer-components'
|
|
25
26
|
import { DialogPortal } from '../ui/DialogPortal'
|
|
26
27
|
import type { SelectedResource, WorkloadRevision } from '../../types'
|
|
27
28
|
import { displayKindName } from '../ui/drawer-components'
|
|
28
29
|
import { getDefaultContainerName } from '../resources/resource-utils'
|
|
30
|
+
import { SetImageDialog, type ManagedImageSource } from './SetImageDialog'
|
|
31
|
+
import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
|
|
32
|
+
import { isArgoRolloutResource } from '../../utils/workload-rollout'
|
|
33
|
+
import { isCoreBatchJob } from '../../utils/api-resources'
|
|
29
34
|
|
|
30
35
|
// ============================================================================
|
|
31
36
|
// ACTIONS BAR - Interactive buttons that change based on resource kind
|
|
@@ -76,6 +81,11 @@ interface ResourceActionsBarProps {
|
|
|
76
81
|
onRestart?: (params: { kind: string; namespace: string; name: string }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
|
|
77
82
|
isRestarting?: boolean
|
|
78
83
|
|
|
84
|
+
onLoadImages?: (params: { kind: string; namespace: string; name: string }) => Promise<WorkloadImageInventory>
|
|
85
|
+
onSetImages?: (params: { kind: string; namespace: string; name: string; updates: WorkloadImageUpdate[] }) => Promise<unknown>
|
|
86
|
+
isSettingImages?: boolean
|
|
87
|
+
managedImageSources?: ManagedImageSource[]
|
|
88
|
+
|
|
79
89
|
// Rollback
|
|
80
90
|
revisions?: WorkloadRevision[]
|
|
81
91
|
revisionsLoading?: boolean
|
|
@@ -136,6 +146,7 @@ export function ResourceActionsBar({
|
|
|
136
146
|
renderPortForward,
|
|
137
147
|
onDelete, isDeleting, cascadeDependents, cascadeLoading, cascadeRootResolved,
|
|
138
148
|
onRestart, isRestarting,
|
|
149
|
+
onLoadImages, onSetImages, isSettingImages, managedImageSources,
|
|
139
150
|
revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
|
|
140
151
|
onRolloutPromoteFull,
|
|
141
152
|
onTriggerCronJob, isTriggeringCronJob,
|
|
@@ -156,11 +167,14 @@ export function ResourceActionsBar({
|
|
|
156
167
|
onDrainNode, isDrainingNode,
|
|
157
168
|
}: ResourceActionsBarProps) {
|
|
158
169
|
const kind = resource.kind.toLowerCase()
|
|
170
|
+
const coreBatchJob = isCoreBatchJob(kind, resource.group)
|
|
171
|
+
const supportsWorkloadActions = ['deployments', 'statefulsets', 'daemonsets'].includes(kind) ||
|
|
172
|
+
(kind === 'rollouts' && isArgoRolloutResource(data))
|
|
159
173
|
const canOpenWorkloadLogs = Boolean(
|
|
160
174
|
canViewLogs &&
|
|
161
175
|
!hideLogs &&
|
|
162
176
|
openWorkloadLogs &&
|
|
163
|
-
['deployments', 'statefulsets', 'daemonsets', '
|
|
177
|
+
(['deployments', 'statefulsets', 'daemonsets', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(kind) || coreBatchJob)
|
|
164
178
|
)
|
|
165
179
|
|
|
166
180
|
// Delete confirmation state
|
|
@@ -173,6 +187,7 @@ export function ResourceActionsBar({
|
|
|
173
187
|
|
|
174
188
|
// Rollback dialog state
|
|
175
189
|
const [showRevisions, setShowRevisions] = useState(false)
|
|
190
|
+
const [showSetImage, setShowSetImage] = useState(false)
|
|
176
191
|
const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind)
|
|
177
192
|
const hasMultipleRevisions = (revisionsList?.length ?? 0) > 1
|
|
178
193
|
|
|
@@ -235,7 +250,7 @@ export function ResourceActionsBar({
|
|
|
235
250
|
}
|
|
236
251
|
|
|
237
252
|
return (
|
|
238
|
-
<div className="flex items-center gap-
|
|
253
|
+
<div className="flex items-center gap-1.5 px-4 py-2 flex-wrap">
|
|
239
254
|
{/* Kind-specific actions (left) */}
|
|
240
255
|
{kind === 'pods' && (
|
|
241
256
|
<>
|
|
@@ -298,7 +313,7 @@ export function ResourceActionsBar({
|
|
|
298
313
|
{canExec && onOpenNodeTerminal && (
|
|
299
314
|
<button
|
|
300
315
|
onClick={() => onOpenNodeTerminal({ nodeName: resource.name })}
|
|
301
|
-
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-lg"
|
|
316
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand-muted rounded-lg"
|
|
302
317
|
>
|
|
303
318
|
<Terminal className="w-3.5 h-3.5" />
|
|
304
319
|
Debug Shell
|
|
@@ -357,29 +372,44 @@ export function ResourceActionsBar({
|
|
|
357
372
|
)}
|
|
358
373
|
|
|
359
374
|
{/* Workload actions - restart, rollback, and logs */}
|
|
360
|
-
{
|
|
375
|
+
{supportsWorkloadActions && (
|
|
361
376
|
<>
|
|
362
|
-
{
|
|
377
|
+
{onLoadImages && onSetImages && !data?.metadata?.deletionTimestamp && (
|
|
363
378
|
<button
|
|
364
|
-
onClick={() =>
|
|
365
|
-
|
|
366
|
-
namespace: resource.namespace,
|
|
367
|
-
name: resource.name,
|
|
368
|
-
})}
|
|
369
|
-
disabled={isRestarting}
|
|
370
|
-
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand-muted rounded-lg"
|
|
379
|
+
onClick={() => setShowSetImage(true)}
|
|
380
|
+
className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium btn-brand-muted rounded-lg"
|
|
371
381
|
>
|
|
372
|
-
<
|
|
373
|
-
|
|
382
|
+
<Box className="w-3.5 h-3.5" />
|
|
383
|
+
Set image
|
|
374
384
|
</button>
|
|
375
385
|
)}
|
|
386
|
+
{onRestart && (
|
|
387
|
+
<>
|
|
388
|
+
<button
|
|
389
|
+
onClick={() => onRestart({
|
|
390
|
+
kind: resource.kind,
|
|
391
|
+
namespace: resource.namespace,
|
|
392
|
+
name: resource.name,
|
|
393
|
+
})}
|
|
394
|
+
disabled={isRestarting}
|
|
395
|
+
aria-busy={isRestarting}
|
|
396
|
+
className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium btn-brand-muted rounded-lg"
|
|
397
|
+
>
|
|
398
|
+
<RefreshCw className={`w-3.5 h-3.5 ${isRestarting ? 'animate-spin' : ''}`} />
|
|
399
|
+
Restart
|
|
400
|
+
</button>
|
|
401
|
+
<span className="sr-only" aria-live="polite">
|
|
402
|
+
{isRestarting ? 'Restarting workload' : ''}
|
|
403
|
+
</span>
|
|
404
|
+
</>
|
|
405
|
+
)}
|
|
376
406
|
{isRollbackKind && onRollback && (
|
|
377
407
|
<Tooltip content={hasMultipleRevisions ? 'View revision history and rollback' : 'Only one revision exists'} delay={150}>
|
|
378
408
|
<button
|
|
379
409
|
onClick={() => setShowRevisions(true)}
|
|
380
410
|
disabled={!hasMultipleRevisions}
|
|
381
411
|
className={clsx(
|
|
382
|
-
"flex items-center gap-1.5 px-
|
|
412
|
+
"flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded-lg transition-colors",
|
|
383
413
|
hasMultipleRevisions
|
|
384
414
|
? "text-white bg-amber-600 hover:bg-amber-700"
|
|
385
415
|
: "text-theme-text-disabled bg-theme-elevated"
|
|
@@ -480,7 +510,7 @@ export function ResourceActionsBar({
|
|
|
480
510
|
workloadKind: kind,
|
|
481
511
|
workloadName: resource.name,
|
|
482
512
|
})}
|
|
483
|
-
className="flex items-center gap-1.5 px-
|
|
513
|
+
className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium btn-brand-muted rounded-lg"
|
|
484
514
|
>
|
|
485
515
|
<FileText className="w-3.5 h-3.5" />
|
|
486
516
|
Logs
|
|
@@ -488,7 +518,7 @@ export function ResourceActionsBar({
|
|
|
488
518
|
)}
|
|
489
519
|
|
|
490
520
|
{/* Job logs */}
|
|
491
|
-
{
|
|
521
|
+
{coreBatchJob && onCopyCommand && (!canViewLogs || !openWorkloadLogs) && (
|
|
492
522
|
<button
|
|
493
523
|
onClick={(e) => onCopyCommand(
|
|
494
524
|
`kubectl logs job/${resource.name} -n ${resource.namespace} -f`,
|
|
@@ -527,7 +557,7 @@ export function ResourceActionsBar({
|
|
|
527
557
|
<button
|
|
528
558
|
onClick={onToggleYaml}
|
|
529
559
|
className={clsx(
|
|
530
|
-
'flex items-center gap-1.5 px-
|
|
560
|
+
'flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded-lg transition-colors',
|
|
531
561
|
showYaml
|
|
532
562
|
? 'btn-brand'
|
|
533
563
|
: 'text-theme-text-secondary hover:text-theme-text-primary border border-theme-border-light hover:bg-theme-elevated'
|
|
@@ -616,6 +646,20 @@ export function ResourceActionsBar({
|
|
|
616
646
|
cascadeRootResolved={cascadeRootResolved}
|
|
617
647
|
/>
|
|
618
648
|
|
|
649
|
+
{onLoadImages && onSetImages && (
|
|
650
|
+
<SetImageDialog
|
|
651
|
+
open={showSetImage}
|
|
652
|
+
workloadLabel={`${displayKindName(resource.kind, data?.kind)} ${resource.namespace}/${resource.name}`}
|
|
653
|
+
workloadName={resource.name}
|
|
654
|
+
workloadResource={resource.kind}
|
|
655
|
+
managedSources={managedImageSources}
|
|
656
|
+
pending={isSettingImages}
|
|
657
|
+
onClose={() => setShowSetImage(false)}
|
|
658
|
+
onLoad={() => onLoadImages({ kind: resource.kind, namespace: resource.namespace, name: resource.name })}
|
|
659
|
+
onConfirm={(updates) => onSetImages({ kind: resource.kind, namespace: resource.namespace, name: resource.name, updates })}
|
|
660
|
+
/>
|
|
661
|
+
)}
|
|
662
|
+
|
|
619
663
|
{/* Node cordon confirmation */}
|
|
620
664
|
<ConfirmDialog
|
|
621
665
|
open={showCordonConfirm}
|
|
@@ -915,17 +959,55 @@ export function revisionRoleBadges(rev: WorkloadRevision, isRollout: boolean): R
|
|
|
915
959
|
return badges
|
|
916
960
|
}
|
|
917
961
|
|
|
918
|
-
// Awaits promote-full so the dialog cannot close on a half-landed rollback
|
|
919
|
-
//
|
|
962
|
+
// Awaits promote-full so the dialog cannot close on a half-landed rollback, and reports
|
|
963
|
+
// whether it landed: the rollback has already succeeded by this point, so a failure here
|
|
964
|
+
// leaves the Rollout replaying its canary steps and the operator has to be told which
|
|
965
|
+
// half of the pair still needs them.
|
|
966
|
+
export interface PromoteFailure {
|
|
967
|
+
message: string
|
|
968
|
+
// Only a lagging controller clears on its own. The status cannot say that — a lost
|
|
969
|
+
// cluster connection also answers 503 — so this reads the error code the server sends.
|
|
970
|
+
controllerLagging: boolean
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// What the operator is told when the rollback landed and the promotion did not. Kept
|
|
974
|
+
// apart from the markup so the wording and the retry decision can be tested.
|
|
975
|
+
export function promoteFailureGuidance(failure: PromoteFailure): {
|
|
976
|
+
body: string
|
|
977
|
+
canRetry: boolean
|
|
978
|
+
} {
|
|
979
|
+
const shared =
|
|
980
|
+
'The rollback is live and is re-running the canary steps you asked to skip. ' +
|
|
981
|
+
'If any of them is a manual pause it will stop there until someone promotes it.'
|
|
982
|
+
if (failure.controllerLagging) {
|
|
983
|
+
return {
|
|
984
|
+
body: `${shared} The Argo Rollouts controller had not caught up yet, which usually clears within a few seconds.`,
|
|
985
|
+
canRetry: true,
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
body: `${shared} Promote full is also available from the rollout page once this is resolved.`,
|
|
990
|
+
canRetry: false,
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
920
994
|
export async function completePromoteAfterRollback(
|
|
921
995
|
promote: () => void | Promise<unknown>,
|
|
922
996
|
setPending: (pending: boolean) => void,
|
|
923
|
-
): Promise<
|
|
997
|
+
): Promise<{ promoted: boolean; error?: PromoteFailure }> {
|
|
924
998
|
setPending(true)
|
|
925
999
|
try {
|
|
926
1000
|
await promote()
|
|
927
|
-
|
|
928
|
-
|
|
1001
|
+
return { promoted: true }
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
const code = (err as { data?: { error_code?: string } })?.data?.error_code
|
|
1004
|
+
return {
|
|
1005
|
+
promoted: false,
|
|
1006
|
+
error: {
|
|
1007
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1008
|
+
controllerLagging: code === 'controller_not_caught_up',
|
|
1009
|
+
},
|
|
1010
|
+
}
|
|
929
1011
|
} finally {
|
|
930
1012
|
setPending(false)
|
|
931
1013
|
}
|
|
@@ -948,6 +1030,8 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
|
|
|
948
1030
|
const [diffRevision, setDiffRevision] = useState<number | null>(null)
|
|
949
1031
|
const [promoteAfterRollback, setPromoteAfterRollback] = useState(false)
|
|
950
1032
|
const [promotingFull, setPromotingFull] = useState(false)
|
|
1033
|
+
const [promoteError, setPromoteError] = useState<PromoteFailure | null>(null)
|
|
1034
|
+
const retryButtonRef = useRef<HTMLButtonElement>(null)
|
|
951
1035
|
|
|
952
1036
|
const handleClose = () => { setDiffRevision(null); onClose() }
|
|
953
1037
|
|
|
@@ -958,20 +1042,34 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
|
|
|
958
1042
|
const isRollout = isRolloutKind(kind)
|
|
959
1043
|
const canPromoteAfterRollback = offersPromoteAfterRollback(kind, Boolean(onRolloutPromoteFull))
|
|
960
1044
|
|
|
1045
|
+
// The Confirm button unmounts when the panel appears, so focus would land on <body>
|
|
1046
|
+
// and a keyboard user would have to tab the whole dialog to reach the retry.
|
|
1047
|
+
useEffect(() => {
|
|
1048
|
+
if (promoteError) retryButtonRef.current?.focus()
|
|
1049
|
+
}, [promoteError])
|
|
1050
|
+
|
|
961
1051
|
const currentRevision = revisions?.find(r => r.isCurrent)
|
|
962
1052
|
const selectedRevision = revisions?.find(r => r.number === diffRevision)
|
|
963
1053
|
const hasDiffData = currentRevision?.template && selectedRevision?.template
|
|
964
1054
|
|
|
965
1055
|
function handleRollback(revision: number) {
|
|
1056
|
+
setPromoteError(null)
|
|
966
1057
|
onRollback?.(
|
|
967
1058
|
{ kind, namespace, name, revision },
|
|
968
1059
|
{
|
|
969
1060
|
onSuccess: async () => {
|
|
970
1061
|
if (canPromoteAfterRollback && promoteAfterRollback && onRolloutPromoteFull) {
|
|
971
|
-
await completePromoteAfterRollback(
|
|
1062
|
+
const outcome = await completePromoteAfterRollback(
|
|
972
1063
|
() => onRolloutPromoteFull({ namespace, name }),
|
|
973
1064
|
setPromotingFull,
|
|
974
1065
|
)
|
|
1066
|
+
if (!outcome.promoted) {
|
|
1067
|
+
// Closing here would leave the operator with a green "rollback initiated"
|
|
1068
|
+
// toast and a canary still running every step they asked to skip.
|
|
1069
|
+
setPromoteError(outcome.error ?? { message: 'The promotion did not go through.', controllerLagging: false })
|
|
1070
|
+
setConfirmRevision(null)
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
975
1073
|
}
|
|
976
1074
|
setConfirmRevision(null)
|
|
977
1075
|
setDiffRevision(null)
|
|
@@ -981,6 +1079,21 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
|
|
|
981
1079
|
)
|
|
982
1080
|
}
|
|
983
1081
|
|
|
1082
|
+
async function retryPromoteFull() {
|
|
1083
|
+
if (!onRolloutPromoteFull) return
|
|
1084
|
+
const outcome = await completePromoteAfterRollback(
|
|
1085
|
+
() => onRolloutPromoteFull({ namespace, name }),
|
|
1086
|
+
setPromotingFull,
|
|
1087
|
+
)
|
|
1088
|
+
if (outcome.promoted) {
|
|
1089
|
+
setPromoteError(null)
|
|
1090
|
+
setDiffRevision(null)
|
|
1091
|
+
onClose()
|
|
1092
|
+
return
|
|
1093
|
+
}
|
|
1094
|
+
setPromoteError(outcome.error ?? { message: 'The promotion did not go through.', controllerLagging: false })
|
|
1095
|
+
}
|
|
1096
|
+
|
|
984
1097
|
function formatTimeAgo(dateStr: string): string {
|
|
985
1098
|
const date = new Date(dateStr)
|
|
986
1099
|
const now = new Date()
|
|
@@ -1120,7 +1233,11 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
|
|
|
1120
1233
|
disabled={busy}
|
|
1121
1234
|
className="px-2 py-0.5 text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 rounded transition-colors disabled:opacity-50"
|
|
1122
1235
|
>
|
|
1123
|
-
{promotingFull
|
|
1236
|
+
{promotingFull
|
|
1237
|
+
? 'Waiting for the controller…'
|
|
1238
|
+
: isRollingBack
|
|
1239
|
+
? 'Rolling back...'
|
|
1240
|
+
: 'Confirm'}
|
|
1124
1241
|
</button>
|
|
1125
1242
|
<button
|
|
1126
1243
|
onClick={() => setConfirmRevision(null)}
|
|
@@ -1157,6 +1274,34 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
|
|
|
1157
1274
|
)}
|
|
1158
1275
|
</div>
|
|
1159
1276
|
|
|
1277
|
+
{promoteError && (
|
|
1278
|
+
<div className="mx-4 shrink-0" role="alert">
|
|
1279
|
+
<AlertBanner
|
|
1280
|
+
variant="warning"
|
|
1281
|
+
title="Rolled back, but not promoted"
|
|
1282
|
+
message={
|
|
1283
|
+
<>
|
|
1284
|
+
{promoteFailureGuidance(promoteError).body}
|
|
1285
|
+
<span className="block mt-1 text-theme-text-tertiary break-words">
|
|
1286
|
+
{promoteError.message}
|
|
1287
|
+
</span>
|
|
1288
|
+
</>
|
|
1289
|
+
}
|
|
1290
|
+
>
|
|
1291
|
+
{promoteFailureGuidance(promoteError).canRetry && (
|
|
1292
|
+
<button
|
|
1293
|
+
ref={retryButtonRef}
|
|
1294
|
+
onClick={retryPromoteFull}
|
|
1295
|
+
disabled={busy}
|
|
1296
|
+
className="mt-2 px-2 py-0.5 text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 rounded transition-colors disabled:opacity-50"
|
|
1297
|
+
>
|
|
1298
|
+
{promotingFull ? 'Waiting for the controller…' : 'Promote fully'}
|
|
1299
|
+
</button>
|
|
1300
|
+
)}
|
|
1301
|
+
</AlertBanner>
|
|
1302
|
+
</div>
|
|
1303
|
+
)}
|
|
1304
|
+
|
|
1160
1305
|
<div className="flex items-center justify-between gap-4 p-4 border-t border-theme-border shrink-0">
|
|
1161
1306
|
{canPromoteAfterRollback ? (
|
|
1162
1307
|
<label className="flex items-center gap-2 text-sm text-theme-text-secondary">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
3
|
import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
|
|
4
|
+
import { getCellFilterValue } from '../resources/resource-utils'
|
|
4
5
|
import type { ResourceRef } from '../../types'
|
|
5
6
|
|
|
6
7
|
function renderWithScalers(scalers: ResourceRef[]): string {
|
|
@@ -179,6 +180,78 @@ function renderCollidingKind(kind: string, apiVersion: string): string {
|
|
|
179
180
|
)
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
describe('getResourceStatus — workload rollout activity', () => {
|
|
184
|
+
const steadyDegraded = {
|
|
185
|
+
metadata: { generation: 4 },
|
|
186
|
+
spec: { replicas: 3 },
|
|
187
|
+
status: { observedGeneration: 4, replicas: 3, updatedReplicas: 3, readyReplicas: 2, availableReplicas: 2 },
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it('keeps steady-state availability as health instead of rollout activity', () => {
|
|
191
|
+
expect(getResourceStatus('deployments', steadyDegraded)).toMatchObject({ text: '2/3', level: 'degraded' })
|
|
192
|
+
expect(getCellFilterValue(steadyDegraded, 'status', 'deployments')).toBe('Degraded')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('uses the rollout label consistently while a revision is moving', () => {
|
|
196
|
+
const rolling = {
|
|
197
|
+
metadata: { generation: 4 },
|
|
198
|
+
spec: { replicas: 3 },
|
|
199
|
+
status: { observedGeneration: 4, replicas: 4, updatedReplicas: 2, readyReplicas: 3, availableReplicas: 3 },
|
|
200
|
+
}
|
|
201
|
+
expect(getResourceStatus('deployments', rolling)?.text).toBe('Rolling out')
|
|
202
|
+
expect(getCellFilterValue(rolling, 'status', 'deployments')).toBe('Rolling out')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('keeps scaled-to-zero workloads neutral', () => {
|
|
206
|
+
const scaled = { metadata: { generation: 2 }, spec: { replicas: 0 }, status: { observedGeneration: 2 } }
|
|
207
|
+
expect(getResourceStatus('deployments', scaled)).toMatchObject({ text: 'Scaled to 0', level: 'neutral' })
|
|
208
|
+
expect(getCellFilterValue(scaled, 'status', 'deployments')).toBe('Scaled to 0')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('treats a foreign Rollout CRD as unknown', () => {
|
|
212
|
+
const rollout = {
|
|
213
|
+
apiVersion: 'rollouts.kruise.io/v1alpha1',
|
|
214
|
+
kind: 'Rollout',
|
|
215
|
+
metadata: { name: 'foreign' },
|
|
216
|
+
spec: { replicas: 3 },
|
|
217
|
+
status: { readyReplicas: 3, availableReplicas: 3, updatedReplicas: 3 },
|
|
218
|
+
}
|
|
219
|
+
expect(getResourceStatus('rollouts', rollout)).toMatchObject({ text: 'Unknown', level: 'unknown' })
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('uses merged rollout and serving health for Argo Rollouts', () => {
|
|
223
|
+
const rollout = {
|
|
224
|
+
apiVersion: 'argoproj.io/v1alpha1',
|
|
225
|
+
metadata: { generation: 2 },
|
|
226
|
+
spec: { replicas: 3 },
|
|
227
|
+
status: {
|
|
228
|
+
observedGeneration: '2',
|
|
229
|
+
phase: 'Progressing',
|
|
230
|
+
updatedReplicas: 1,
|
|
231
|
+
readyReplicas: 0,
|
|
232
|
+
availableReplicas: 0,
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
expect(getResourceStatus('rollouts', rollout)).toMatchObject({ text: 'Degraded · Rolling out', level: 'degraded' })
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe('Rollout kind collision', () => {
|
|
241
|
+
it('routes foreign Rollouts to the generic renderer', () => {
|
|
242
|
+
const html = renderKind('rollouts', {
|
|
243
|
+
apiVersion: 'rollouts.kruise.io/v1alpha1',
|
|
244
|
+
kind: 'Rollout',
|
|
245
|
+
metadata: { name: 'foreign', namespace: 'default' },
|
|
246
|
+
spec: { collisionProbe: COLLISION_PROBE },
|
|
247
|
+
status: {},
|
|
248
|
+
}, 'default')
|
|
249
|
+
|
|
250
|
+
expect(html).toContain(COLLISION_PROBE)
|
|
251
|
+
expect(html).not.toContain('Rollout Strategy')
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
182
255
|
describe('getResourceStatus — colliding plurals', () => {
|
|
183
256
|
it('fabricates no PostgreSQL status for a third-party clusters CRD', () => {
|
|
184
257
|
// KubeBlocks, Redis/Valkey operators and friends all ship `clusters`. With
|
|
@@ -582,14 +655,27 @@ describe('shared plurals — the status path collides too', () => {
|
|
|
582
655
|
// The renderer and the status badge are two separate switches over the same
|
|
583
656
|
// plural, and fixing one leaves the other reading a foreign CRD's conditions
|
|
584
657
|
// as if they were Knative's.
|
|
658
|
+
// Phase-only on purpose. Knative reports a bare "Unknown" for a resource with
|
|
659
|
+
// no conditions, while the generic path reports the phase — so this fixture
|
|
660
|
+
// tells the two apart. A Ready=False condition would not: both paths surface
|
|
661
|
+
// the same text for it, and pinning which of phase/conditions wins is a
|
|
662
|
+
// separate concern that generic-status.test.ts owns.
|
|
585
663
|
it('does not give a foreign subscriptions CRD a Knative status', () => {
|
|
586
664
|
const s = getResourceStatus('subscriptions', {
|
|
587
665
|
apiVersion: 'operators.coreos.com/v1alpha1',
|
|
588
|
-
status: {
|
|
666
|
+
status: { phase: 'AtLatestKnown' },
|
|
589
667
|
})
|
|
590
668
|
expect(s?.text).toBe('AtLatestKnown')
|
|
591
669
|
})
|
|
592
670
|
|
|
671
|
+
it('still routes a Knative subscription to the Knative status', () => {
|
|
672
|
+
const s = getResourceStatus('subscriptions', {
|
|
673
|
+
apiVersion: 'messaging.knative.dev/v1',
|
|
674
|
+
status: { phase: 'AtLatestKnown' },
|
|
675
|
+
})
|
|
676
|
+
expect(s?.text).toBe('Unknown')
|
|
677
|
+
})
|
|
678
|
+
|
|
593
679
|
it('still gives Knative its own status', () => {
|
|
594
680
|
const s = getResourceStatus('subscriptions', {
|
|
595
681
|
apiVersion: 'messaging.knative.dev/v1',
|
|
@@ -640,3 +726,122 @@ describe('Velero BackupRepository status', () => {
|
|
|
640
726
|
expect(getResourceStatus('backuprepositories', foreign)?.text).not.toBe('Not ready')
|
|
641
727
|
})
|
|
642
728
|
})
|
|
729
|
+
|
|
730
|
+
describe('GPU ecosystem kind collisions', () => {
|
|
731
|
+
it('routes Volcano Jobs away from the core JobRenderer to GenericRenderer', () => {
|
|
732
|
+
const html = renderKind('jobs', {
|
|
733
|
+
apiVersion: 'batch.volcano.sh/v1alpha1',
|
|
734
|
+
kind: 'Job',
|
|
735
|
+
metadata: { name: 'train', namespace: 'ml' },
|
|
736
|
+
spec: { queue: 'gpu-queue', minAvailable: 4 },
|
|
737
|
+
status: { state: { phase: 'Running' } },
|
|
738
|
+
}, 'ml')
|
|
739
|
+
expect(html).toContain('Min Available')
|
|
740
|
+
expect(html).toContain('gpu-queue')
|
|
741
|
+
expect(html).not.toContain('Completions')
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it('routes status for queues by group (Volcano vs KAI)', () => {
|
|
745
|
+
const volcano = getResourceStatus('queues', {
|
|
746
|
+
apiVersion: 'scheduling.volcano.sh/v1beta1',
|
|
747
|
+
status: { state: 'Open' },
|
|
748
|
+
})
|
|
749
|
+
const kai = getResourceStatus('queues', {
|
|
750
|
+
apiVersion: 'scheduling.run.ai/v2',
|
|
751
|
+
spec: { priority: 100 },
|
|
752
|
+
})
|
|
753
|
+
expect(volcano?.text).toBe('Open')
|
|
754
|
+
expect(kai?.text).not.toBe('Open')
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
it('routes status for podgroups by group (Volcano vs KAI)', () => {
|
|
758
|
+
const volcano = getResourceStatus('podgroups', {
|
|
759
|
+
apiVersion: 'scheduling.volcano.sh/v1beta1',
|
|
760
|
+
status: { phase: 'Running' },
|
|
761
|
+
})
|
|
762
|
+
expect(volcano?.text).toBe('Running')
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
it('routes Kueue Workload status only for the kueue group', () => {
|
|
766
|
+
const admitted = getResourceStatus('workloads', {
|
|
767
|
+
apiVersion: 'kueue.x-k8s.io/v1beta2',
|
|
768
|
+
status: { conditions: [{ type: 'Admitted', status: 'True' }] },
|
|
769
|
+
})
|
|
770
|
+
expect(admitted?.text).toBe('Admitted')
|
|
771
|
+
const foreign = getResourceStatus('workloads', {
|
|
772
|
+
apiVersion: 'scheduling.k8s.io/v1alpha2',
|
|
773
|
+
status: { conditions: [{ type: 'Admitted', status: 'True' }] },
|
|
774
|
+
})
|
|
775
|
+
expect(foreign?.text).toBe('Admitted')
|
|
776
|
+
expect(foreign?.color).not.toBe(admitted?.color)
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
it('does not assign Volcano or core Job semantics to foreign collisions', () => {
|
|
780
|
+
const foreign = {
|
|
781
|
+
apiVersion: 'example.io/v1',
|
|
782
|
+
kind: 'Job',
|
|
783
|
+
metadata: { name: 'foreign', namespace: 'default' },
|
|
784
|
+
spec: { collisionProbe: COLLISION_PROBE },
|
|
785
|
+
status: { state: { phase: 'Running' } },
|
|
786
|
+
}
|
|
787
|
+
expect(getResourceStatus('jobs', foreign)).toBeNull()
|
|
788
|
+
const html = renderKind('jobs', foreign, 'default')
|
|
789
|
+
expect(html).toContain(COLLISION_PROBE)
|
|
790
|
+
expect(html).not.toContain('Completions')
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
it('keeps typed core Jobs on the core path when TypeMeta is absent', () => {
|
|
794
|
+
const core = {
|
|
795
|
+
kind: 'Job',
|
|
796
|
+
metadata: { name: 'core', namespace: 'default' },
|
|
797
|
+
spec: { completions: 1, parallelism: 1 },
|
|
798
|
+
status: { succeeded: 1, conditions: [{ type: 'Complete', status: 'True' }] },
|
|
799
|
+
}
|
|
800
|
+
expect(getResourceStatus('jobs', core)?.text).toBe('Complete')
|
|
801
|
+
const html = renderKind('jobs', core, 'default')
|
|
802
|
+
expect(html).toContain('Completions')
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('leaves foreign Queues and PodGroups on generic status handling', () => {
|
|
806
|
+
expect(getResourceStatus('queues', { apiVersion: 'example.io/v1', status: {} })).toBeNull()
|
|
807
|
+
expect(getResourceStatus('podgroups', { apiVersion: 'example.io/v1', status: {} })).toBeNull()
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it('accepts the current llm-d InferenceObjective group', () => {
|
|
811
|
+
expect(getResourceStatus('inferenceobjectives', {
|
|
812
|
+
apiVersion: 'llm-d.ai/v1alpha2',
|
|
813
|
+
status: { conditions: [{ type: 'Ready', status: 'True' }] },
|
|
814
|
+
})?.text).toBe('Accepted')
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('routes KAITO Workspace status only for the kaito group', () => {
|
|
818
|
+
const ws = getResourceStatus('workspaces', {
|
|
819
|
+
apiVersion: 'kaito.sh/v1beta1',
|
|
820
|
+
status: { conditions: [{ type: 'ResourceReady', status: 'False' }] },
|
|
821
|
+
})
|
|
822
|
+
expect(ws).not.toBeNull()
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
describe('GPU ecosystem status edge cases', () => {
|
|
827
|
+
it('JobSet with minimal status is Pending, not Running', () => {
|
|
828
|
+
const fresh = getResourceStatus('jobsets', {
|
|
829
|
+
apiVersion: 'jobset.x-k8s.io/v1alpha2',
|
|
830
|
+
status: { replicatedJobsStatus: [{ name: 'w', active: 0, ready: 0 }] },
|
|
831
|
+
})
|
|
832
|
+
const live = getResourceStatus('jobsets', {
|
|
833
|
+
apiVersion: 'jobset.x-k8s.io/v1alpha2',
|
|
834
|
+
status: { replicatedJobsStatus: [{ name: 'w', active: 1, ready: 1 }] },
|
|
835
|
+
})
|
|
836
|
+
expect(fresh?.text).toBe('Pending')
|
|
837
|
+
expect(live?.text).toBe('Running')
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
it('InferencePool with only an empty-parentRef default entry reads Not referenced', () => {
|
|
841
|
+
const pool = getResourceStatus('inferencepools', {
|
|
842
|
+
apiVersion: 'inference.networking.x-k8s.io/v1alpha2',
|
|
843
|
+
status: { parent: [{ parentRef: {}, conditions: [{ type: 'Accepted', status: 'Unknown' }] }] },
|
|
844
|
+
})
|
|
845
|
+
expect(pool?.text).toBe('Not referenced')
|
|
846
|
+
})
|
|
847
|
+
})
|