@skyhook-io/k8s-ui 1.18.2 → 1.20.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 (152) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/charts/AreaChart.test.tsx +39 -1
  4. package/src/components/charts/AreaChart.tsx +32 -13
  5. package/src/components/charts/axis.test.ts +17 -1
  6. package/src/components/charts/axis.ts +13 -0
  7. package/src/components/charts/format.test.ts +159 -2
  8. package/src/components/charts/format.ts +118 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  11. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  12. package/src/components/compare/ResourceCompareView.tsx +35 -12
  13. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  14. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  15. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  17. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  18. package/src/components/gitops/RollbackDialog.tsx +25 -6
  19. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  21. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  22. package/src/components/gitops/applicationset-row.test.ts +12 -0
  23. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  24. package/src/components/gitops/detail-helpers.test.ts +8 -2
  25. package/src/components/gitops/detail-helpers.ts +9 -11
  26. package/src/components/gitops/index.ts +1 -1
  27. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  28. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  29. package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
  30. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  31. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  32. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  33. package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
  34. package/src/components/gitops/insights/insights-helpers.ts +17 -3
  35. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  36. package/src/components/issues/IssueGuidance.tsx +48 -0
  37. package/src/components/issues/IssuesView.tsx +15 -3
  38. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  39. package/src/components/issues/diagnostic.ts +14 -1
  40. package/src/components/issues/index.ts +1 -0
  41. package/src/components/issues/severity.ts +15 -0
  42. package/src/components/issues/types.ts +10 -0
  43. package/src/components/kueue/AdmissionPath.tsx +223 -0
  44. package/src/components/kueue/QueueEvidence.tsx +148 -0
  45. package/src/components/kueue/constants.ts +1 -0
  46. package/src/components/kueue/eligibility.ts +43 -0
  47. package/src/components/kueue/flavors-tried.ts +79 -0
  48. package/src/components/kueue/helpers.test.ts +105 -0
  49. package/src/components/kueue/policy.ts +100 -0
  50. package/src/components/kueue/queue-detail.test.tsx +317 -0
  51. package/src/components/kueue/queue-status.ts +42 -0
  52. package/src/components/kueue/related-read.ts +48 -0
  53. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  54. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  55. package/src/components/logs/StructuredLogLine.tsx +8 -3
  56. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  57. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  58. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  59. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  60. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  61. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  62. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  63. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  64. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  65. package/src/components/resources/ResourcesView.tsx +50 -3
  66. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  67. package/src/components/resources/generic-status.ts +7 -0
  68. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  69. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  71. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  72. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  73. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  74. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  75. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  76. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  77. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  78. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  79. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  80. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  81. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  82. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  83. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  84. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  85. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  86. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  87. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  88. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  89. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  90. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  91. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  92. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  93. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  94. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  95. package/src/components/resources/renderers/index.ts +6 -0
  96. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  97. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  98. package/src/components/resources/resource-utils-argo.ts +84 -5
  99. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  100. package/src/components/resources/resource-utils-crd.ts +26 -0
  101. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  102. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  103. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  104. package/src/components/resources/resource-utils-kueue.ts +137 -4
  105. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  106. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  107. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  108. package/src/components/resources/resource-utils-ray.ts +38 -0
  109. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  110. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  111. package/src/components/resources/resource-utils.ts +45 -1
  112. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  113. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  114. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  115. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  116. package/src/components/topology/K8sResourceNode.tsx +18 -1
  117. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  118. package/src/components/topology/TopologyGraph.tsx +96 -94
  119. package/src/components/topology/layout.ts +3 -1
  120. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  121. package/src/components/ui/Badge.tsx +1 -0
  122. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  123. package/src/components/ui/drawer-components.tsx +1 -1
  124. package/src/components/ui/index.ts +1 -0
  125. package/src/components/workload/PodList.tsx +35 -9
  126. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  127. package/src/components/workload/WorkloadView.tsx +61 -8
  128. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  129. package/src/types/core.ts +41 -0
  130. package/src/types/gitops-insights.ts +19 -2
  131. package/src/types/gitops-tree.ts +2 -2
  132. package/src/types/kueue-queues.ts +84 -0
  133. package/src/types/read-coverage.ts +2 -1
  134. package/src/types/scheduling.ts +72 -0
  135. package/src/utils/animation.test.ts +38 -0
  136. package/src/utils/animation.ts +34 -0
  137. package/src/utils/api-resources-crd.test.ts +11 -0
  138. package/src/utils/api-resources.ts +5 -1
  139. package/src/utils/applications.test.ts +15 -1
  140. package/src/utils/applications.ts +26 -1
  141. package/src/utils/crd-schema.test.ts +107 -0
  142. package/src/utils/crd-schema.ts +201 -0
  143. package/src/utils/format.test.ts +37 -1
  144. package/src/utils/format.ts +28 -80
  145. package/src/utils/gitops-owner.test.ts +28 -2
  146. package/src/utils/gitops-owner.ts +19 -0
  147. package/src/utils/kueue-queue-workloads.ts +19 -0
  148. package/src/utils/resource-icons.ts +2 -0
  149. package/src/utils/topology-neighborhood.test.ts +90 -1
  150. package/src/utils/topology-neighborhood.ts +55 -3
  151. package/src/utils/workload-history-notes.test.ts +76 -0
  152. package/src/utils/workload-history-notes.ts +93 -0
@@ -30,6 +30,9 @@ import type { SelectedResource, WorkloadRevision } from '../../types'
30
30
  import type { RadarUpgradeRequirement } from '../../types/fetch-error'
31
31
  import { displayKindName } from '../ui/drawer-components'
32
32
  import { getDefaultContainerName, getExecContainerNames } from '../resources/resource-utils'
33
+ import { argoApplicationSyncOptions, argoComparedRevisionCurrent, isArgoAutoSyncEnabled, isArgoAutoSyncSwitchedOff, isArgoMultiSource, isArgoOperationInProgress } from '../resources/resource-utils-argo'
34
+ import { SyncOptionsDialog, type ArgoSyncOpts } from '../gitops/SyncOptionsDialog'
35
+ import { radarUpgradeNoteText } from '../ui/RadarUpgradeNote'
33
36
  import { SetImageDialog, type SetImageOwnership } from './SetImageDialog'
34
37
  import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
35
38
  import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
@@ -129,14 +132,25 @@ interface ResourceActionsBarProps {
129
132
  isFluxResuming?: boolean
130
133
 
131
134
  // Argo actions
132
- onArgoSync?: (params: { namespace: string; name: string }) => void
135
+ // May return a promise that settles with the request: the options dialog
136
+ // then stays open, with its choices, when it rejects.
137
+ onArgoSync?: (params: { namespace: string; name: string; applicationSyncOptions?: string[] } & Partial<ArgoSyncOpts>) => void | Promise<unknown>
133
138
  isArgoSyncing?: boolean
139
+ // Set when onArgoSync forwards the options in its params: Sync then opens
140
+ // the options dialog. Without it Sync syncs at once with the Application's
141
+ // own options. pinsComparedRevision: the Radar pins an omitted revision
142
+ // (argoSyncInheritance).
143
+ argoSyncDialog?: { pinsComparedRevision: boolean }
134
144
  onArgoRefresh?: (params: { namespace: string; name: string; hard: boolean }) => void
135
145
  isArgoRefreshing?: boolean
136
146
  onArgoSuspend?: (params: { namespace: string; name: string }) => void
137
147
  isArgoSuspending?: boolean
138
148
  onArgoResume?: (params: { namespace: string; name: string }) => void
139
149
  isArgoResuming?: boolean
150
+ // Set when the connected Radar is too old to turn auto-sync back on for an
151
+ // Application with automated.enabled: false; Enable Auto-Sync renders disabled.
152
+ argoResumeUpgrade?: RadarUpgradeRequirement | null
153
+ argoResumeBlockedReason?: string
140
154
 
141
155
  // Node debug shell
142
156
  onOpenNodeTerminal?: (params: { nodeName: string }) => void
@@ -183,10 +197,10 @@ export function ResourceActionsBar({
183
197
  onFluxSyncWithSource, isFluxSyncing,
184
198
  onFluxSuspend, isFluxSuspending,
185
199
  onFluxResume, isFluxResuming,
186
- onArgoSync, isArgoSyncing,
200
+ onArgoSync, isArgoSyncing, argoSyncDialog,
187
201
  onArgoRefresh, isArgoRefreshing,
188
202
  onArgoSuspend, isArgoSuspending,
189
- onArgoResume, isArgoResuming,
203
+ onArgoResume, isArgoResuming, argoResumeUpgrade, argoResumeBlockedReason,
190
204
  onOpenNodeTerminal,
191
205
  canNodeWrite,
192
206
  onCordonNode, isCordoningNode,
@@ -556,12 +570,15 @@ export function ResourceActionsBar({
556
570
  data={data}
557
571
  onSync={onArgoSync}
558
572
  isSyncing={isArgoSyncing}
573
+ syncDialog={argoSyncDialog}
559
574
  onRefresh={onArgoRefresh}
560
575
  isRefreshing={isArgoRefreshing}
561
576
  onSuspend={onArgoSuspend}
562
577
  isSuspending={isArgoSuspending}
563
578
  onResume={onArgoResume}
564
579
  isResuming={isArgoResuming}
580
+ resumeUpgrade={argoResumeUpgrade}
581
+ resumeBlockedReason={argoResumeBlockedReason}
565
582
  />
566
583
  )}
567
584
 
@@ -881,32 +898,71 @@ function FluxActions({ resource, data, onReconcile, isReconciling, onSyncWithSou
881
898
  // ARGO ACTIONS
882
899
  // ============================================================================
883
900
 
884
- function ArgoActions({ resource, data, onSync, isSyncing, onRefresh, isRefreshing, onSuspend, isSuspending, onResume, isResuming }: {
901
+ function ArgoActions({ resource, data, onSync, isSyncing, syncDialog, onRefresh, isRefreshing, onSuspend, isSuspending, onResume, isResuming, resumeUpgrade, resumeBlockedReason }: {
885
902
  resource: SelectedResource; data: any
886
- onSync?: (params: { namespace: string; name: string }) => void; isSyncing?: boolean
903
+ onSync?: (params: { namespace: string; name: string; applicationSyncOptions?: string[] } & Partial<ArgoSyncOpts>) => void | Promise<unknown>; isSyncing?: boolean
904
+ syncDialog?: { pinsComparedRevision: boolean }
887
905
  onRefresh?: (params: { namespace: string; name: string; hard: boolean }) => void; isRefreshing?: boolean
888
906
  onSuspend?: (params: { namespace: string; name: string }) => void; isSuspending?: boolean
889
907
  onResume?: (params: { namespace: string; name: string }) => void; isResuming?: boolean
908
+ resumeUpgrade?: RadarUpgradeRequirement | null
909
+ resumeBlockedReason?: string
890
910
  }) {
891
- const hasAutomatedSync = !!data?.spec?.syncPolicy?.automated
911
+ const autoSyncEnabled = isArgoAutoSyncEnabled(data)
912
+ const resumeBlocked = isArgoAutoSyncSwitchedOff(data)
913
+ ? resumeBlockedReason || (resumeUpgrade ? radarUpgradeNoteText(resumeUpgrade) : undefined)
914
+ : undefined
915
+
916
+ // The drawer can switch Applications under an open dialog; its choices
917
+ // belong to the one it was opened for, so it closes on any other.
918
+ const appKey = `${resource.namespace}/${resource.name}`
919
+ const [syncDialogFor, setSyncDialogFor] = useState<string | null>(null)
920
+ if (syncDialogFor !== null && syncDialogFor !== appKey) setSyncDialogFor(null)
921
+ const syncDialogOpen = syncDialogFor === appKey
922
+ const [syncError, setSyncError] = useState<string | null>(null)
923
+ const operationRunning = isArgoOperationInProgress(data)
924
+ const applicationSyncOptions = argoApplicationSyncOptions(data)
925
+ // A failed request is reported by the host; it only keeps the dialog open.
926
+ const sync = (opts?: ArgoSyncOpts) => Promise.resolve(onSync?.({ namespace: resource.namespace, name: resource.name, applicationSyncOptions, ...opts }))
892
927
 
893
928
  return (
894
929
  <>
895
930
  {onSync && (
896
- <Tooltip content="Sync application" delay={150}>
931
+ <Tooltip content={operationRunning ? 'Wait for the current Argo operation to finish.' : syncDialog ? 'Apply manifests from Git to the cluster. Opens an options dialog (prune, dry-run, revision).' : "Syncs with the Application's own sync options, without pruning. For prune, dry run or a revision, use Sync… on its GitOps page."} delay={150}>
897
932
  <button
898
- onClick={() => onSync({
899
- namespace: resource.namespace,
900
- name: resource.name,
901
- })}
902
- disabled={isSyncing}
933
+ onClick={() => {
934
+ if (!syncDialog) return void sync().catch(() => {})
935
+ setSyncError(null)
936
+ setSyncDialogFor(appKey)
937
+ }}
938
+ disabled={isSyncing || operationRunning}
903
939
  className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand rounded-lg"
904
940
  >
905
941
  <RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} />
906
- {isSyncing ? 'Syncing...' : 'Sync'}
942
+ {isSyncing ? 'Syncing...' : syncDialog ? 'Sync…' : 'Sync'}
907
943
  </button>
908
944
  </Tooltip>
909
945
  )}
946
+ {onSync && syncDialog && (
947
+ <SyncOptionsDialog
948
+ key={appKey}
949
+ open={syncDialogOpen}
950
+ appLabel={`${resource.namespace}/${resource.name}`}
951
+ multiSource={isArgoMultiSource(data)}
952
+ comparedRevision={data?.status?.sync?.revision}
953
+ pinsComparedRevision={syncDialog.pinsComparedRevision && argoComparedRevisionCurrent(data)}
954
+ applicationSyncOptions={applicationSyncOptions}
955
+ pending={isSyncing}
956
+ autoSyncEnabled={isArgoAutoSyncEnabled(data)}
957
+ operationInProgress={operationRunning}
958
+ syncError={syncError}
959
+ onCancel={() => setSyncDialogFor(null)}
960
+ onConfirm={(opts) => {
961
+ setSyncError(null)
962
+ sync(opts).then(() => setSyncDialogFor(null), (error: unknown) => setSyncError(error instanceof Error ? error.message : 'the request was refused'))
963
+ }}
964
+ />
965
+ )}
910
966
 
911
967
  {onRefresh && (
912
968
  <Tooltip content="Refresh (re-read from git)" delay={150}>
@@ -925,7 +981,7 @@ function ArgoActions({ resource, data, onSync, isSyncing, onRefresh, isRefreshin
925
981
  </Tooltip>
926
982
  )}
927
983
 
928
- {hasAutomatedSync ? (
984
+ {autoSyncEnabled ? (
929
985
  onSuspend && (
930
986
  <button
931
987
  onClick={() => onSuspend({
@@ -941,17 +997,19 @@ function ArgoActions({ resource, data, onSync, isSyncing, onRefresh, isRefreshin
941
997
  )
942
998
  ) : (
943
999
  onResume && (
944
- <button
945
- onClick={() => onResume({
946
- namespace: resource.namespace,
947
- name: resource.name,
948
- })}
949
- disabled={isResuming}
950
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50"
951
- >
952
- <Play className="w-3.5 h-3.5" />
953
- {isResuming ? 'Enabling...' : 'Enable Auto-Sync'}
954
- </button>
1000
+ <Tooltip content={resumeBlocked} disabled={!resumeBlocked} delay={150}>
1001
+ <button
1002
+ onClick={() => !resumeBlocked && onResume({
1003
+ namespace: resource.namespace,
1004
+ name: resource.name,
1005
+ })}
1006
+ disabled={isResuming || !!resumeBlocked}
1007
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1008
+ >
1009
+ <Play className="w-3.5 h-3.5" />
1010
+ {isResuming ? 'Enabling...' : 'Enable Auto-Sync'}
1011
+ </button>
1012
+ </Tooltip>
955
1013
  )
956
1014
  )}
957
1015
  </>
@@ -1,6 +1,8 @@
1
+ import { HEALTH_BADGE_COLORS } from '../../utils/badge-colors'
2
+ import { getGenericResourceStatus } from '../resources/generic-status'
1
3
  import { describe, expect, it } from 'vitest'
2
4
  import { renderToString } from 'react-dom/server'
3
- import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
5
+ import { ResourceRendererDispatch, diagnoseHealthHint, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
4
6
  import { getCellFilterValue } from '../resources/resource-utils'
5
7
  import type { ResourceRef } from '../../types'
6
8
 
@@ -1027,11 +1029,11 @@ describe('Kueue Workload detail dispatch', () => {
1027
1029
  describe('Kueue queue detail dispatch', () => {
1028
1030
  it.each(['localqueues', 'clusterqueues'])('curates supported %s and preserves foreign and future versions', (kind) => {
1029
1031
  for (const version of ['v1beta1', 'v1beta2']) {
1030
- expect(renderKind(kind, { apiVersion: `kueue.x-k8s.io/${version}`, spec: {} })).toContain('Queue Status')
1032
+ expect(renderKind(kind, { apiVersion: `kueue.x-k8s.io/${version}`, spec: {} })).toContain('Reported by controller')
1031
1033
  }
1032
1034
  for (const apiVersion of ['example.com/v1', 'fake.kueue.x-k8s.io/v1', 'kueue.x-k8s.io/v99']) {
1033
1035
  const html = renderKind(kind, { apiVersion, spec: { foreignField: 'preserved-value' } })
1034
- expect(html).not.toContain('Queue Status')
1036
+ expect(html).not.toContain('Reported by controller')
1035
1037
  expect(html).toContain('preserved-value')
1036
1038
  }
1037
1039
  })
@@ -1136,3 +1138,35 @@ describe('Flux kind collisions', () => {
1136
1138
  }
1137
1139
  })
1138
1140
  })
1141
+
1142
+ describe('Strimzi KafkaConnector exact group', () => {
1143
+ const data = {apiVersion: 'kafka.strimzi.io/v1', kind: 'KafkaConnector', metadata: {name:'connector',generation:1}, spec: {class:'Example'}, status:{observedGeneration:1,conditions:[{type:'Ready',status:'True'}],connectorStatus:{connector:{state:'RUNNING'},tasks:[{id:0,state:'FAILED'}]}}}
1144
+ it('shows failed tasks despite Ready=True', () => {
1145
+ expect(getResourceStatus('kafkaconnectors', data)?.text).toBe('Tasks failed')
1146
+ expect(renderKind('kafkaconnectors', data)).toContain('Reported Runtime')
1147
+ })
1148
+ it('leaves unrelated and lookalike groups generic', () => {
1149
+ for (const apiVersion of ['other.io/v1', 'fake.kafka.strimzi.io/v1']) {
1150
+ const other = {...data,apiVersion}
1151
+ expect(getResourceStatus('kafkaconnectors',other)?.text).not.toBe('Tasks failed')
1152
+ expect(renderKind('kafkaconnectors',other)).not.toContain('Reported Runtime')
1153
+ }
1154
+ })
1155
+ })
1156
+
1157
+ it('keeps foreign customresourcedefinitions on generic renderer and status', () => {
1158
+ const data = { apiVersion: 'foreign.io/v1', kind: 'CustomResourceDefinition', metadata: { name: 'foreign' }, spec: { example: 'generic-sentinel' }, status: { conditions: [{ type: 'Established', status: 'True' }] } }
1159
+ const html = renderToString(<ResourceRendererDispatch resource={{ kind: 'customresourcedefinitions', name: 'foreign', namespace: '' }} data={data} onCopy={() => {}} copied={null} showCommonSections={false} />)
1160
+ expect(html).toContain('generic-sentinel')
1161
+ expect(html).not.toContain('Names &amp; conversion')
1162
+ expect(getGenericResourceStatus(data)).toEqual({ text: 'Established', tone: 'unknown' })
1163
+ expect(getResourceStatus('customresourcedefinitions', data)).toEqual({ text: 'Established', color: HEALTH_BADGE_COLORS.unknown })
1164
+ })
1165
+
1166
+ it('gives Diagnose the CRD health the header badge shows', () => {
1167
+ const crd = (conditions: any[], extra: any = {}) => ({ apiVersion: 'apiextensions.k8s.io/v1', kind: 'CustomResourceDefinition', metadata: { name: 'widgets.example.io', ...extra }, status: { conditions } })
1168
+ expect(diagnoseHealthHint('customresourcedefinitions', crd([{ type: 'Established', status: 'True' }]))).toBe('healthy')
1169
+ expect(diagnoseHealthHint('customresourcedefinitions', crd([{ type: 'NamesAccepted', status: 'False', reason: 'ListKindConflict' }]))).toBe('problem')
1170
+ expect(diagnoseHealthHint('customresourcedefinitions', crd([{ type: 'Established', status: 'True' }], { deletionTimestamp: '2026-01-01T00:00:00Z' }))).toBe('problem')
1171
+ expect(diagnoseHealthHint('customresourcedefinitions', crd([]))).toBe('unknown')
1172
+ })
@@ -1,3 +1,6 @@
1
+ import { getCRDStatus } from '../resources/resource-utils-crd'
2
+ import { getStrimziConnectorStatus } from '../resources/resource-utils-strimzi'
3
+ import { StrimziConnectorRenderer } from '../resources/renderers/StrimziConnectorRenderer'
1
4
  import { getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from '../resources/resource-utils-opentelemetry'
2
5
  import { OpenTelemetryCollectorRenderer, OpenTelemetryInstrumentationRenderer } from '../resources/renderers/OpenTelemetryRenderers'
3
6
  import { FlaggerCanaryRenderer, FlaggerMetricTemplateRenderer } from '../resources/renderers/FlaggerRenderers'
@@ -132,6 +135,7 @@ import {
132
135
  CronJobRenderer,
133
136
  CronWorkflowRenderer,
134
137
  HPARenderer,
138
+ CustomResourceDefinitionRenderer,
135
139
  NodeRenderer,
136
140
  PVCRenderer,
137
141
  RolloutRenderer,
@@ -321,13 +325,21 @@ import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
321
325
  * When an override is not provided, the base (shared) renderer is used.
322
326
  */
323
327
  export interface RendererOverrides {
328
+ CustomResourceDefinitionRenderer?: React.ComponentType<{
329
+ data: any
330
+ onNavigate?: (ref: ResourceRef) => void
331
+ }>
332
+
333
+ OpenTelemetryCollectorRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void; active?: boolean }>
324
334
  FluxHelmReleaseRenderer?: React.ComponentType<React.ComponentProps<typeof FluxHelmReleaseRenderer>>
325
335
  JobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
326
336
  JobSetRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
327
337
  RayJobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
328
338
  RayClusterRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
329
339
  RayServiceRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
330
- KueueWorkloadRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
340
+ KueueWorkloadRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void; relationships?: Relationships }>
341
+ LocalQueueRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
342
+ ClusterQueueRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
331
343
  PodRenderer?: React.ComponentType<{
332
344
  data: any; onCopy: CopyHandler; copied: string | null
333
345
  onNavigate?: (ref: ResourceRef) => void
@@ -455,6 +467,8 @@ export interface RendererOverrides {
455
467
 
456
468
  // Known resource types with specific renderers (module-level to avoid re-allocation)
457
469
  const KNOWN_KINDS = new Set([
470
+ 'customresourcedefinitions',
471
+ 'kafkaconnectors',
458
472
  'opentelemetrycollectors', 'instrumentations',
459
473
  'canaries', 'metrictemplates',
460
474
  'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
@@ -558,6 +572,8 @@ function isCAPITopologyGroup(apiVersion?: string): boolean {
558
572
  // ============================================================================
559
573
 
560
574
  interface ResourceRendererDispatchProps {
575
+ /** Whether the containing resource view is active; hosts can pause their reads. */
576
+ active?: boolean
561
577
  resource: SelectedResource
562
578
  data: any
563
579
  relationships?: Relationships
@@ -606,6 +622,7 @@ interface ResourceRendererDispatchProps {
606
622
  }
607
623
 
608
624
  export function ResourceRendererDispatch({
625
+ active = true,
609
626
  resource,
610
627
  data,
611
628
  relationships,
@@ -716,6 +733,8 @@ export function ResourceRendererDispatch({
716
733
  const isFluxKind = ['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind)
717
734
  const isFluxResource = getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
718
735
  const isGroupGatedKind =
736
+ kind === 'customresourcedefinitions' ||
737
+ kind === 'kafkaconnectors' ||
719
738
  ['opentelemetrycollectors', 'instrumentations'].includes(kind) ||
720
739
  ['canaries', 'metrictemplates'].includes(kind) ||
721
740
  isFluxKind || kind === 'applications' || kind === 'services' || kind === 'ingresses' || kind === 'certificates' || kind === 'gateways'
@@ -727,6 +746,8 @@ export function ResourceRendererDispatch({
727
746
  || kind === 'machines' || kind === 'machinesets' || kind === 'workloads' || kind === 'localqueues' || kind === 'clusterqueues'
728
747
  const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
729
748
  const groupGatedMatched =
749
+ (kind === 'customresourcedefinitions' && isApiGroup(data?.apiVersion, 'apiextensions.k8s.io')) ||
750
+ (kind === 'kafkaconnectors' && isApiGroup(data?.apiVersion, 'kafka.strimzi.io')) ||
730
751
  (['opentelemetrycollectors', 'instrumentations'].includes(kind) && isApiGroup(data?.apiVersion, 'opentelemetry.io')) ||
731
752
  (['canaries', 'metrictemplates'].includes(kind) && isApiGroup(data?.apiVersion, 'flagger.app')) ||
732
753
  (isFluxKind && isFluxResource)
@@ -785,8 +806,11 @@ export function ResourceRendererDispatch({
785
806
  const JobSetComp = rendererOverrides?.JobSetRenderer ?? JobSetRenderer
786
807
  const RayJobComp = rendererOverrides?.RayJobRenderer ?? RayJobRenderer
787
808
  const RayClusterComp = rendererOverrides?.RayClusterRenderer ?? RayClusterRenderer
809
+ const CollectorComp = rendererOverrides?.OpenTelemetryCollectorRenderer ?? OpenTelemetryCollectorRenderer
788
810
  const RayServiceComp = rendererOverrides?.RayServiceRenderer ?? RayServiceRenderer
789
811
  const KueueWorkloadComp = rendererOverrides?.KueueWorkloadRenderer ?? KueueWorkloadRenderer
812
+ const LocalQueueComp = rendererOverrides?.LocalQueueRenderer ?? LocalQueueRenderer
813
+ const ClusterQueueComp = rendererOverrides?.ClusterQueueRenderer ?? ClusterQueueRenderer
790
814
  const PodComp = rendererOverrides?.PodRenderer ?? PodRenderer
791
815
  const KarpenterNodePoolComp = rendererOverrides?.KarpenterNodePoolRenderer ?? KarpenterNodePoolRenderer
792
816
  const WorkloadComp = rendererOverrides?.WorkloadRenderer ?? WorkloadRenderer
@@ -813,6 +837,7 @@ export function ResourceRendererDispatch({
813
837
  const RoleBindingComp = rendererOverrides?.RoleBindingRenderer ?? RoleBindingRenderer
814
838
  const NamespaceComp = rendererOverrides?.NamespaceRenderer ?? NamespaceRenderer
815
839
  const CAPIClusterComp = rendererOverrides?.CAPIClusterRenderer ?? CAPIClusterRenderer
840
+ const CRDComp = rendererOverrides?.CustomResourceDefinitionRenderer ?? CustomResourceDefinitionRenderer
816
841
  const HPAComp = rendererOverrides?.HPARenderer ?? HPARenderer
817
842
  const PVCComp = rendererOverrides?.PVCRenderer ?? PVCRenderer
818
843
  const RolloutComp = rendererOverrides?.RolloutRenderer ?? RolloutRenderer
@@ -858,11 +883,11 @@ export function ResourceRendererDispatch({
858
883
  {kind === 'rayjobs' && data?.apiVersion === 'ray.io/v1' && <RayJobComp data={data} onNavigate={onNavigate} />}
859
884
  {kind === 'rayclusters' && data?.apiVersion === 'ray.io/v1' && <RayClusterComp data={data} onNavigate={onNavigate} />}
860
885
  {kind === 'rayservices' && data?.apiVersion === 'ray.io/v1' && <RayServiceComp data={data} onNavigate={onNavigate} />}
861
- {kind === 'workloads' && isApiGroup(data?.apiVersion, 'kueue.x-k8s.io') && <KueueWorkloadComp data={data} onNavigate={onNavigate} />}
886
+ {kind === 'workloads' && isApiGroup(data?.apiVersion, 'kueue.x-k8s.io') && <KueueWorkloadComp data={data} onNavigate={onNavigate} relationships={relationships} />}
862
887
  {kind === 'admissionchecks' && isKueueQueueResource(data) && <AdmissionCheckRenderer data={data} onNavigate={onNavigate} />}
863
888
  {kind === 'provisioningrequests' && ['autoscaling.x-k8s.io/v1', 'autoscaling.x-k8s.io/v1beta1'].includes(data?.apiVersion) && <ProvisioningRequestRenderer data={data} onNavigate={onNavigate} />}
864
- {kind === 'localqueues' && isKueueQueueResource(data) && <LocalQueueRenderer data={data} onNavigate={onNavigate} />}
865
- {kind === 'clusterqueues' && isKueueQueueResource(data) && <ClusterQueueRenderer data={data} onNavigate={onNavigate} />}
889
+ {kind === 'localqueues' && isKueueQueueResource(data) && <LocalQueueComp data={data} onNavigate={onNavigate} />}
890
+ {kind === 'clusterqueues' && isKueueQueueResource(data) && <ClusterQueueComp data={data} onNavigate={onNavigate} />}
866
891
  {kind === 'jobsets' && isJobSetV1Alpha2(data) && <JobSetComp data={data} onNavigate={onNavigate} />}
867
892
  {kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
868
893
  {kind === 'cronworkflows' && <CronWorkflowRenderer data={data} onNavigate={onNavigate} />}
@@ -1006,9 +1031,10 @@ export function ResourceRendererDispatch({
1006
1031
  {kind === 'serviceentries' && <IstioServiceEntryRenderer data={data} />}
1007
1032
  {kind === 'peerauthentications' && <IstioPeerAuthenticationRenderer data={data} />}
1008
1033
  {kind === 'authorizationpolicies' && <IstioAuthorizationPolicyRenderer data={data} />}
1009
- {kind === 'mutatingwebhookconfigurations' && <WebhookConfigRenderer data={data} isMutating />}
1010
- {kind === 'validatingwebhookconfigurations' && <WebhookConfigRenderer data={data} />}
1034
+ {kind === 'mutatingwebhookconfigurations' && <WebhookConfigRenderer data={data} isMutating onNavigate={onNavigate} />}
1035
+ {kind === 'validatingwebhookconfigurations' && <WebhookConfigRenderer data={data} onNavigate={onNavigate} />}
1011
1036
  {kind === 'ingressclasses' && <IngressClassRenderer data={data} onNavigate={onNavigate} />}
1037
+ {kind === 'customresourcedefinitions' && isApiGroup(data?.apiVersion, 'apiextensions.k8s.io') && <CRDComp data={data} onNavigate={onNavigate} />}
1012
1038
  {kind === 'priorityclasses' && <PriorityClassRenderer data={data} />}
1013
1039
  {kind === 'runtimeclasses' && <RuntimeClassRenderer data={data} />}
1014
1040
  {kind === 'leases' && <LeaseRenderer data={data} />}
@@ -1063,7 +1089,8 @@ export function ResourceRendererDispatch({
1063
1089
  {isCrossplaneMR && <ManagedResourceRenderer data={data} onNavigate={onNavigate} />}
1064
1090
  {(isCrossplaneXR || isCrossplaneClaim) && <CompositeComp data={data} onNavigate={onNavigate} />}
1065
1091
 
1066
- {kind === 'opentelemetrycollectors' && isApiGroup(data?.apiVersion, 'opentelemetry.io') && <OpenTelemetryCollectorRenderer data={data} />}
1092
+ {kind === 'opentelemetrycollectors' && isApiGroup(data?.apiVersion, 'opentelemetry.io') && <CollectorComp data={data} onNavigate={onNavigate} active={active} />}
1093
+ {kind === 'kafkaconnectors' && isApiGroup(data?.apiVersion, 'kafka.strimzi.io') && <StrimziConnectorRenderer data={data} onNavigate={onNavigate} />}
1067
1094
  {kind === 'instrumentations' && isApiGroup(data?.apiVersion, 'opentelemetry.io') && <OpenTelemetryInstrumentationRenderer data={data} />}
1068
1095
  {kind === 'canaries' && isApiGroup(data?.apiVersion, 'flagger.app') && <FlaggerCanaryRenderer data={data} onNavigate={onNavigate} />}
1069
1096
  {kind === 'metrictemplates' && isApiGroup(data?.apiVersion, 'flagger.app') && <FlaggerMetricTemplateRenderer data={data} onNavigate={onNavigate} />}
@@ -1124,6 +1151,12 @@ export function diagnoseHealthHint(kind: string, data: any): DiagnoseHealthHint
1124
1151
  export function getResourceStatus(kind: string, data: any): { text: string; color: string } | null {
1125
1152
  if (!data) return null
1126
1153
  const k = kind.toLowerCase()
1154
+ if (k === 'customresourcedefinitions' && isApiGroup(data.apiVersion, 'apiextensions.k8s.io')) {
1155
+ const status = getCRDStatus(data)
1156
+ const badge = { text: status.text, color: HEALTH_BADGE_COLORS[status.tone], level: status.tone }
1157
+ return badge
1158
+ }
1159
+ if (k === 'kafkaconnectors' && isApiGroup(data.apiVersion, 'kafka.strimzi.io')) return getStrimziConnectorStatus(data)
1127
1160
 
1128
1161
  if (isApiGroup(data.apiVersion, 'opentelemetry.io')) {
1129
1162
  if (k === 'opentelemetrycollectors') return getOpenTelemetryCollectorStatus(data)
@@ -2,6 +2,10 @@ import { describe, expect, it } from 'vitest'
2
2
  import { baseSubtitle, knativeDomainsTooltipLines } from './K8sResourceNode'
3
3
 
4
4
  describe('baseSubtitle', () => {
5
+ it('explains earlier Job runs on grouped and loose Pods', () => {
6
+ expect(baseSubtitle('PodGroup', { earlierJobRun: 'train-workers-0', terminating: 6 })).toBe('Earlier run • terminating')
7
+ expect(baseSubtitle('Pod', { earlierJobRun: 'hook', terminating: 0 })).toBe('Earlier run')
8
+ })
5
9
  it('describes workflow templates without dropping newer topology kinds', () => {
6
10
  expect(baseSubtitle('WorkflowTemplate', { entrypoint: 'main', templateCount: 3 })).toBe('main • 3 templates')
7
11
  expect(baseSubtitle('ClusterWorkflowTemplate', { templateCount: 2 })).toBe('2 templates')
@@ -9,6 +13,16 @@ describe('baseSubtitle', () => {
9
13
  expect(baseSubtitle('ServiceMonitor', { endpointCount: 1 })).toBe('1 scrape endpoint')
10
14
  })
11
15
 
16
+ it('describes a JobSet by its lifecycle and the Jobs it declares, not Jobs it has created', () => {
17
+ const apiVersion = 'jobset.x-k8s.io/v1alpha2'
18
+ expect(baseSubtitle('JobSet', { apiVersion, phase: 'Active', declaredJobs: 8 })).toBe('Active • 8 Jobs declared')
19
+ expect(baseSubtitle('JobSet', { apiVersion, phase: 'Pending', declaredJobs: 1 })).toBe('Pending • 1 Job declared')
20
+ expect(baseSubtitle('JobSet', { apiVersion, phase: 'Unknown' })).toBe('Unknown')
21
+ expect(baseSubtitle('JobSet', { apiVersion })).toBe('')
22
+ // Another group's JobSet has no lifecycle fields to describe.
23
+ expect(baseSubtitle('JobSet', { apiVersion: 'jobset.example.com/v1' })).toBe('')
24
+ })
25
+
12
26
  it('describes a Service by its first port, capping additional ports to a count', () => {
13
27
  expect(baseSubtitle('Service', { type: 'ClusterIP', ports: [] })).toBe('ClusterIP')
14
28
  expect(baseSubtitle('Service', { type: 'ClusterIP', ports: [{ port: 80 }] })).toBe('ClusterIP :80')
@@ -267,6 +267,7 @@ export const NODE_DIMENSIONS: Record<
267
267
  HorizontalPodAutoscaler: { width: 280, height: 84 },
268
268
  Job: { width: 300, height: 84 },
269
269
  CronJob: { width: 200, height: 84 },
270
+ JobSet: { width: 260, height: 84 },
270
271
  Workflow: { width: 400, height: 84 },
271
272
  CronWorkflow: { width: 240, height: 84 },
272
273
  WorkflowTemplate: { width: 240, height: 84 },
@@ -391,6 +392,10 @@ export interface ServicePortEntry {
391
392
  }
392
393
 
393
394
  export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>): string {
395
+ if (typeof nodeData.earlierJobRun === 'string') {
396
+ const state = Number(nodeData.terminating) > 0 ? ' • terminating' : ''
397
+ return `Earlier run${state}`
398
+ }
394
399
  if (nodeData.deploymentMembership === 'source-only') {
395
400
  return `Declared by ${nodeData.deploymentSourceLabel ?? 'deployment source'}`
396
401
  }
@@ -535,6 +540,18 @@ export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>):
535
540
  const instanceType = nodeData.instanceType as string;
536
541
  return instanceType || "";
537
542
  }
543
+ case "JobSet": {
544
+ // Another API group's JobSet comes from the generic pass, without these fields.
545
+ if (!String(nodeData.apiVersion ?? "").startsWith("jobset.x-k8s.io/")) return "";
546
+ // Declared, not observed: a JobSet holding roles behind dependencies
547
+ // has created fewer Jobs than its spec asks for.
548
+ // An older Radar draws an owned JobSet without a phase; its status
549
+ // color then comes from generic conditions, so don't claim "Unknown".
550
+ const phase = (nodeData.phase as string) || "";
551
+ if (!phase) return "";
552
+ const declared = nodeData.declaredJobs as number | undefined;
553
+ return typeof declared === "number" ? `${phase} • ${declared} ${declared === 1 ? "Job" : "Jobs"} declared` : phase;
554
+ }
538
555
  case "AnalysisRun": {
539
556
  const phase = (nodeData.phase as string) || "Unknown";
540
557
  const trigger = nodeData.trigger as string;
@@ -839,7 +856,7 @@ export const K8sResourceNode = memo(function K8sResourceNode({
839
856
  </div>
840
857
  </Tooltip>
841
858
  ) : (
842
- <div className="text-xs text-theme-text-secondary truncate mt-0.5">
859
+ <div className="text-xs text-theme-text-secondary truncate mt-0.5" title={typeof nodeData.earlierJobRun === 'string' ? `Earlier run of Job ${nodeData.earlierJobRun}` : undefined}>
843
860
  {subtitle}
844
861
  </div>
845
862
  )
@@ -51,6 +51,7 @@ const RESOURCE_KINDS: {
51
51
  { kind: 'PodGroup', label: 'Pod Group', icon: getTopologyIcon('PodGroup'), color: 'text-lime-400', category: 'workloads' },
52
52
  { kind: 'Job', label: 'Job', icon: getTopologyIcon('Job'), color: 'text-orange-400', category: 'workloads' },
53
53
  { kind: 'CronJob', label: 'CronJob', icon: getTopologyIcon('CronJob'), color: 'text-orange-300', category: 'workloads' },
54
+ { kind: 'JobSet', label: 'JobSet', icon: getTopologyIcon('JobSet'), color: 'text-orange-400', category: 'workloads' },
54
55
  { kind: 'Workflow', label: 'Workflow', icon: getTopologyIcon('Workflow'), color: 'text-purple-400', category: 'workloads' },
55
56
  { kind: 'CronWorkflow', label: 'CronWorkflow', icon: getTopologyIcon('CronWorkflow'), color: 'text-purple-300', category: 'workloads' },
56
57