@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
@@ -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', 'jobs', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(kind)
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-2 px-4 py-2 flex-wrap">
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
- {['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind) && (
375
+ {supportsWorkloadActions && (
361
376
  <>
362
- {onRestart && (
377
+ {onLoadImages && onSetImages && !data?.metadata?.deletionTimestamp && (
363
378
  <button
364
- onClick={() => onRestart({
365
- kind: resource.kind,
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
- <RefreshCw className={`w-3.5 h-3.5 ${isRestarting ? 'animate-spin' : ''}`} />
373
- {isRestarting ? 'Restarting...' : 'Restart'}
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-3 py-1.5 text-xs font-medium rounded-lg transition-colors",
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-3 py-1.5 text-xs font-medium btn-brand-muted rounded-lg"
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
- {kind === 'jobs' && onCopyCommand && (!canViewLogs || !openWorkloadLogs) && (
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-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
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. A
919
- // failure is reported by the mutation's error toast, so it must not block the close.
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<void> {
997
+ ): Promise<{ promoted: boolean; error?: PromoteFailure }> {
924
998
  setPending(true)
925
999
  try {
926
1000
  await promote()
927
- } catch {
928
- // already surfaced to the user
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 ? 'Promoting...' : isRollingBack ? 'Rolling back...' : 'Confirm'}
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: { conditions: [{ type: 'Ready', status: 'False' }], phase: 'AtLatestKnown' },
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
+ })