@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
@@ -0,0 +1,37 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { VirtuosoMockContext } from 'react-virtuoso'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { ResourcesView } from './ResourcesView'
7
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
8
+ import type { APIResource } from '../../types'
9
+ import cases from '../../../../../testdata/strimzi/connector-status.json'
10
+ import { getCellFilterValue } from './resource-utils'
11
+
12
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
13
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
14
+ Element.prototype.scrollIntoView = vi.fn()
15
+ let root: ReturnType<typeof createRoot>
16
+ let host: HTMLDivElement
17
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
18
+
19
+ it('replaces the native Ready printer column with task-aware snapshot status', async () => {
20
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
21
+ const kind: APIResource = { group: 'kafka.strimzi.io', version: 'v1', kind: 'KafkaConnector', name: 'kafkaconnectors', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
22
+ const data = {...cases[1].resource, metadata: {...cases[1].resource.metadata, uid: 'fixture'}}
23
+ const second = {...data, metadata:{...data.metadata,name:'zzz',uid:'second'},spec:{...data.spec,class:'AConnector'},status:{...data.status,connectorStatus:{...data.status.connectorStatus,tasks:Array.from({length:10},(_,id)=>({id,state:'RUNNING'}))}}}
24
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView printerTable={{kind:kind.name,group:kind.group,columns:[{name:'Ready',type:'string',jsonPath:'.status.conditions[?(@.type=="Ready")].status'}],cells:{fixture:['True']}}} namespaces={['lab']} apiResources={[kind]} resourceCounts={{ KafkaConnector: 2 }} selectedKindQuery={{ isLoading: false, data: [data,second], resourceName: kind.name, group: kind.group }} locationPathname="/resources/kafkaconnectors" locationSearch="?apiGroup=kafka.strimzi.io" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
25
+ const table = host.querySelector('table')!
26
+ expect(table).not.toBeNull()
27
+ expect(table.textContent).toContain('Operator Snapshot')
28
+ expect(table.textContent).toContain('Tasks failed')
29
+ expect(table.textContent).not.toContain('Ready')
30
+ const classHeader = [...table.querySelectorAll('th')].find(th=>th.textContent?.trim()==='Class')!
31
+ act(() => classHeader.click())
32
+ expect(table.querySelector('tbody tr')?.textContent).toContain('zzz')
33
+ const tasksHeader = [...table.querySelectorAll('th')].find(th=>th.textContent?.trim()==='Tasks / Max')!
34
+ act(() => tasksHeader.click())
35
+ expect(table.querySelector('tbody tr')?.textContent).toContain('connector')
36
+ expect(getCellFilterValue(data, 'status', 'kafkaconnectors')).toBe('Tasks failed')
37
+ })
@@ -1,3 +1,6 @@
1
+ import { CRDVersionsCell } from './renderers/crd-cells'
2
+ import { getCRDColumnValue } from './resource-utils-crd'
3
+ import { getStrimziConnectorColumnValue } from './resource-utils-strimzi'
1
4
  import { getOpenTelemetryColumnValue } from './resource-utils-opentelemetry'
2
5
  import { getFlaggerColumnValue } from './resource-utils-flagger'
3
6
  import type { ResourceCoverage } from '../../types/resource-coverage'
@@ -369,6 +372,7 @@ function phonePinnedLeft(compareMode: boolean | undefined, showCheckbox: boolean
369
372
  // Built-in sortable keys. Hoisted so getColumnMinWidth can reserve room for the
370
373
  // sort icon without the header render and the width math drifting apart.
371
374
  const BUILTIN_SORTABLE_COLUMN_KEYS = new Set([
375
+ 'connectCluster', 'connectorClass', 'connectorTasks',
372
376
  'name', 'namespace', 'age', 'status', 'ready', 'restarts', 'type', 'version',
373
377
  'desired', 'available', 'upToDate', 'lastSeen', 'count', 'reason', 'object',
374
378
  'cpu', 'memory', 'containers',
@@ -579,6 +583,15 @@ const DEFAULT_COLUMNS: Column[] = [
579
583
  ]
580
584
 
581
585
  const KNOWN_COLUMNS: Record<string, Column[]> = {
586
+ kafkaconnectors: [
587
+ { key: 'name', label: 'Name', width: 'min-w-40' },
588
+ { key: 'namespace', label: 'Namespace', width: 'w-40 shrink-0' },
589
+ { key: 'status', label: 'Operator Snapshot', width: 'w-44 shrink-0', tooltip: 'Operator-reported connector and task state, not a live runtime check.' },
590
+ { key: 'connectCluster', label: 'KafkaConnect', width: 'min-w-32' },
591
+ { key: 'connectorClass', label: 'Class', width: 'min-w-48' },
592
+ { key: 'connectorTasks', label: 'Tasks / Max', width: 'w-28 shrink-0', tooltip: 'Reported tasks / configured maximum. A smaller count is not itself a failure.' },
593
+ { key: 'age', label: 'Age', width: 'w-24 shrink-0' },
594
+ ],
582
595
  opentelemetrycollectors: [
583
596
  { key: 'name', label: 'Name', width: 'min-w-40' },
584
597
  { key: 'namespace', label: 'Namespace', width: 'w-40 shrink-0' },
@@ -1009,6 +1022,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1009
1022
  { key: 'status', label: 'Status', width: 'w-24' },
1010
1023
  { key: 'cohort', label: 'Cohort', width: 'w-32', tooltip: 'Queues in this cohort can share unused quota, subject to borrowing and lending limits' },
1011
1024
  { key: 'pendingWorkloads', label: 'Pending', width: 'w-24', tooltip: 'Workloads in this ClusterQueue waiting for admission, across namespaces; inspect workload status for the blocker' },
1025
+ { key: 'reservingWorkloads', label: 'Reserving', width: 'w-24', tooltip: 'Workloads holding a quota reservation in this ClusterQueue, admitted ones included, so it is not added to Admitted' },
1012
1026
  { key: 'admittedWorkloads', label: 'Admitted', width: 'w-24', tooltip: 'Unfinished workloads admitted through this ClusterQueue, across namespaces; admission does not mean their pods are running' },
1013
1027
  { key: 'flavors', label: 'Flavors', width: 'w-40' },
1014
1028
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -1019,6 +1033,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1019
1033
  { key: 'status', label: 'Status', width: 'w-24' },
1020
1034
  { key: 'clusterQueue', label: 'Cluster Queue', width: 'w-40' },
1021
1035
  { key: 'pendingWorkloads', label: 'Pending', width: 'w-24', tooltip: 'Workloads submitted to this LocalQueue that are waiting for ClusterQueue admission' },
1036
+ { key: 'reservingWorkloads', label: 'Reserving', width: 'w-24', tooltip: 'Workloads from this LocalQueue holding a quota reservation, admitted ones included, so it is not added to Admitted' },
1022
1037
  { key: 'admittedWorkloads', label: 'Admitted', width: 'w-24', tooltip: 'Unfinished workloads from this LocalQueue admitted to its ClusterQueue; their pods may still be waiting to run' },
1023
1038
  { key: 'age', label: 'Age', width: 'w-24' },
1024
1039
  ],
@@ -1717,6 +1732,14 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1717
1732
  { key: 'renewTime', label: 'Last Renewed', width: 'w-32', tooltip: 'Time since the recorded renewal; red means it exceeds leaseDurationSeconds. Check holder health and API connectivity' },
1718
1733
  { key: 'age', label: 'Age', width: 'w-24' },
1719
1734
  ],
1735
+ customresourcedefinitions: [
1736
+ { key: 'name', label: 'Name' },
1737
+ { key: 'kind', label: 'Kind', width: 'w-56' },
1738
+ { key: 'scope', label: 'Scope', width: 'w-32' },
1739
+ { key: 'versions', label: 'Served Versions', width: 'w-64' },
1740
+ { key: 'status', label: 'Status', width: 'w-36' },
1741
+ { key: 'age', label: 'Age', width: 'w-24' },
1742
+ ],
1720
1743
  priorityclasses: [
1721
1744
  { key: 'name', label: 'Name' },
1722
1745
  { key: 'value', label: 'Value', width: 'w-24', tooltip: 'Higher values receive scheduling preference; Pods can still be preempted by Pods with higher priority' },
@@ -2914,6 +2937,7 @@ function isCuratedUnboundedGroup(key: string, group: string): boolean {
2914
2937
  * cannot occupy one. See CORE_API_GROUPS.
2915
2938
  */
2916
2939
  const CURATED_COLUMN_GROUPS: Record<string, readonly string[]> = {
2940
+ kafkaconnectors: ['kafka.strimzi.io'],
2917
2941
  opentelemetrycollectors: ['opentelemetry.io'],
2918
2942
  instrumentations: ['opentelemetry.io'],
2919
2943
  canaries: ['flagger.app'],
@@ -4640,12 +4664,12 @@ export function ResourcesView({
4640
4664
  if (containers.length > 0) {
4641
4665
  openLogs?.({ namespace: ns, podName: name, containers })
4642
4666
  }
4643
- } else if (['deployments', 'statefulsets', 'daemonsets', 'replicasets', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(kindLower) || isCoreBatchJob(kindLower, selectedKind.group)) {
4667
+ } else if (['deployments', 'statefulsets', 'daemonsets', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(kindLower) || isCoreBatchJob(kindLower, selectedKind.group)) {
4644
4668
  openWorkloadLogs?.({ namespace: ns, workloadKind: selectedKind.kind, workloadName: name })
4645
4669
  }
4646
4670
  },
4647
4671
  enabled: highlightedIndex >= 0 && !compareMode && (
4648
- ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(selectedKind.name.toLowerCase()) ||
4672
+ ['pods', 'deployments', 'statefulsets', 'daemonsets', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(selectedKind.name.toLowerCase()) ||
4649
4673
  isCoreBatchJob(selectedKind.name, selectedKind.group)
4650
4674
  ),
4651
4675
  },
@@ -5441,6 +5465,11 @@ export function ResourcesView({
5441
5465
  const meta = resource.metadata || {}
5442
5466
  const status = resource.status || {}
5443
5467
  const kindLower = kind?.toLowerCase() || ''
5468
+ const connectorValue = getStrimziConnectorColumnValue(resource, column)
5469
+ if (connectorValue !== undefined) {
5470
+ if (column === 'connectorTasks') return Array.isArray(resource.status?.connectorStatus?.tasks) ? resource.status.connectorStatus.tasks.length : -1
5471
+ return connectorValue
5472
+ }
5444
5473
  const otelValue = getOpenTelemetryColumnValue(resource, column)
5445
5474
  if (otelValue !== undefined) return otelValue
5446
5475
  const flaggerValue = getFlaggerColumnValue(resource, column)
@@ -7579,6 +7608,11 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
7579
7608
  return <KyvernoCleanupPolicyCell resource={resource} column={column} />
7580
7609
  }
7581
7610
 
7611
+ const connectorValue = getStrimziConnectorColumnValue(resource, column)
7612
+ if (connectorValue !== undefined) {
7613
+ if (column === 'status') return <GenericCell resource={resource} column={column} />
7614
+ return <span className="text-sm text-theme-text-secondary break-all">{connectorValue || '-'}</span>
7615
+ }
7582
7616
  if (isApiGroup(resource.apiVersion, 'opentelemetry.io') && ['opentelemetrycollectors', 'instrumentations'].includes(kindLower)) {
7583
7617
  if (column === 'status') return <GenericCell resource={resource} column={column} />
7584
7618
  return <span className="text-sm text-theme-text-secondary break-all">{getOpenTelemetryColumnValue(resource, column) || '-'}</span>
@@ -7703,6 +7737,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
7703
7737
  return <IngressClassCell resource={resource} column={column} />
7704
7738
  case 'leases':
7705
7739
  return <LeaseCell resource={resource} column={column} />
7740
+ case 'customresourcedefinitions':
7741
+ return isApiGroup(resource.apiVersion, 'apiextensions.k8s.io') ? <CRDCell resource={resource} column={column} /> : <GenericCell resource={resource} column={column} />
7706
7742
  case 'priorityclasses':
7707
7743
  return <PriorityClassCell resource={resource} column={column} />
7708
7744
  case 'runtimeclasses':
@@ -9250,7 +9286,10 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
9250
9286
  case 'pods': {
9251
9287
  const m = metrics.nodes.get(resource.metadata?.name)
9252
9288
  const allocatable = resource.status?.allocatable?.pods
9253
- const podCount = m?.podCount ?? 0
9289
+ // No metrics entry means the count wasn't loaded (e.g. node metrics are
9290
+ // off while a namespace is selected), not that the node runs no Pods.
9291
+ if (!m) return <span className="text-sm text-theme-text-tertiary font-mono">-</span>
9292
+ const podCount = m.podCount ?? 0
9254
9293
  if (!allocatable) return <span className="text-sm text-theme-text-tertiary font-mono">{podCount || '-'}</span>
9255
9294
  // Allocatable pods is a Quantity, so a 1000-pod node reports "1k" —
9256
9295
  // a raw-integer read would see 1 and peg the bar at 21600%.
@@ -9912,3 +9951,11 @@ function EventCell({ resource, column }: { resource: any; column: string }) {
9912
9951
  return <span className="text-sm text-theme-text-tertiary">-</span>
9913
9952
  }
9914
9953
  }
9954
+
9955
+ function CRDCell({ resource, column }: { resource: any; column: string }) {
9956
+ if (column === 'status') return <GenericCell resource={resource} column={column} />
9957
+ const spec = resource.spec || {}
9958
+ if (column === 'versions') return <CRDVersionsCell versions={spec.versions || []} />
9959
+ const value = getCRDColumnValue(resource, column)
9960
+ return <span className="text-sm text-theme-text-secondary">{value || '-'}</span>
9961
+ }
@@ -108,8 +108,8 @@ describe('curated column ownership', () => {
108
108
  // Brace-counting, not parsing: assert the extraction actually saw the whole
109
109
  // table, or a silently-missed key would let the guard above pass vacuously.
110
110
  it('extracted every curated key', () => {
111
- expect(CURATED.length).toBe(244)
112
- expect(OWNED.size).toBe(206)
111
+ expect(CURATED.length).toBe(246)
112
+ expect(OWNED.size).toBe(207)
113
113
  expect(CURATED).toContain('pods')
114
114
  expect(CURATED).toContain('crossplanemanagedresources')
115
115
  expect([...OWNED]).toContain('awsmanagedcontrolplanes')
@@ -166,7 +166,7 @@ describe('every declaration resolves', () => {
166
166
  .map(m => [m[1], m[2].split(',').map(g => g.trim().replace(/'/g, ''))] as const)
167
167
 
168
168
  it('covers the whole ownership table', () => {
169
- expect(DECLARED.length).toBe(206)
169
+ expect(DECLARED.length).toBe(207)
170
170
  })
171
171
 
172
172
  it.each(DECLARED)('%s is curated under each group it claims', (key, groups) => {
@@ -1,3 +1,5 @@
1
+ import { getCRDStatus } from './resource-utils-crd'
2
+ import { getStrimziConnectorStatus } from './resource-utils-strimzi'
1
3
  import { getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from './resource-utils-opentelemetry'
2
4
  import { getFlaggerCanaryStatus } from './resource-utils-flagger'
3
5
  import { isApiGroup } from './resource-utils-cnpg'
@@ -91,6 +93,11 @@ function fromCondition(cond: any, polarityKnown: boolean): GenericStatus | null
91
93
  * callers render their own placeholder rather than inventing one.
92
94
  */
93
95
  export function getGenericResourceStatus(resource: any): GenericStatus | null {
96
+ if (resource?.kind === 'CustomResourceDefinition' && isApiGroup(resource.apiVersion, 'apiextensions.k8s.io')) return getCRDStatus(resource)
97
+ if (resource?.kind === 'KafkaConnector' && isApiGroup(resource.apiVersion, 'kafka.strimzi.io')) {
98
+ const native = getStrimziConnectorStatus(resource)
99
+ return { text: native.text, tone: native.level }
100
+ }
94
101
  if (isApiGroup(resource?.apiVersion, 'opentelemetry.io')) {
95
102
  const native = resource.kind === 'OpenTelemetryCollector' ? getOpenTelemetryCollectorStatus(resource)
96
103
  : resource.kind === 'Instrumentation' ? getOpenTelemetryInstrumentationStatus(resource) : null
@@ -568,3 +568,73 @@ describe('getPodPhaseDisplay', () => {
568
568
  expect(r.level).toBe('healthy')
569
569
  })
570
570
  })
571
+
572
+ describe('scheduling-gated pods', () => {
573
+ const oldTs = '2020-01-01T00:00:00Z'
574
+ const gated = {
575
+ metadata: { creationTimestamp: oldTs },
576
+ spec: { schedulingGates: [{ name: 'kueue.x-k8s.io/admission' }] },
577
+ status: { phase: 'Pending' },
578
+ }
579
+
580
+ it('getPodStatus reads a gated pod as neutral "Scheduling gated", not an aged Pending warning', () => {
581
+ const r = getPodStatus(gated)
582
+ expect(r.level).toBe('neutral')
583
+ expect(r.text).toBe('Scheduling gated')
584
+ })
585
+
586
+ it('getPodStatus still ages an ungated Pending pod into a degraded warning', () => {
587
+ const ungated = { metadata: { creationTimestamp: oldTs }, status: { phase: 'Pending' } }
588
+ expect(getPodStatus(ungated).level).toBe('degraded')
589
+ })
590
+
591
+ it('getPodPhaseDisplay explains the hold and names the gate in its hint', () => {
592
+ const r = getPodPhaseDisplay(gated)
593
+ expect(r.level).toBe('neutral')
594
+ expect(r.text).toContain('Scheduling gated')
595
+ expect(r.hint).toContain('kueue.x-k8s.io/admission')
596
+ })
597
+ })
598
+
599
+ describe('scheduling-gated detection on summary list payloads', () => {
600
+ // The summary list payload keeps spec.schedulingGates (with an otherwise minimal
601
+ // spec), so the badge classifies a held pod straight from the authoritative spec
602
+ // field — no dependency on the scheduler's PodScheduled condition.
603
+ const summaryGated = {
604
+ metadata: { creationTimestamp: '2020-01-01T00:00:00Z' },
605
+ spec: { schedulingGates: [{ name: 'kueue.x-k8s.io/admission' }], containers: [{ name: 'trainer' }] },
606
+ status: { phase: 'Pending' },
607
+ }
608
+
609
+ it('getPodStatus reads a summary-shaped gated pod as neutral', () => {
610
+ const r = getPodStatus(summaryGated)
611
+ expect(r.level).toBe('neutral')
612
+ expect(r.text).toBe('Scheduling gated')
613
+ })
614
+
615
+ it('a released pod is aged normally even if a stale SchedulingGated condition lingers', () => {
616
+ // Gates removed (empty spec) but the scheduler has not yet rewritten the
617
+ // condition: the spec is authoritative, so the pod must age, not read as held.
618
+ const released = {
619
+ metadata: { creationTimestamp: '2020-01-01T00:00:00Z' },
620
+ spec: { containers: [{ name: 'trainer' }] },
621
+ status: {
622
+ phase: 'Pending',
623
+ conditions: [{ type: 'PodScheduled', status: 'False', reason: 'SchedulingGated' }],
624
+ },
625
+ }
626
+ expect(getPodStatus(released).level).toBe('degraded')
627
+ expect(getPodStatus(released).text).toBe('Pending')
628
+ })
629
+
630
+ it('an Unschedulable condition is NOT treated as scheduling-gated', () => {
631
+ const unsched = {
632
+ metadata: { creationTimestamp: '2020-01-01T00:00:00Z' },
633
+ status: {
634
+ phase: 'Pending',
635
+ conditions: [{ type: 'PodScheduled', status: 'False', reason: 'Unschedulable' }],
636
+ },
637
+ }
638
+ expect(getPodStatus(unsched).text).toBe('Unschedulable')
639
+ })
640
+ })
@@ -1,7 +1,60 @@
1
- import { renderToStaticMarkup } from 'react-dom/server'
2
1
  import { describe, expect, it } from 'vitest'
2
+ import { renderToStaticMarkup, renderToString } from 'react-dom/server'
3
3
  import { ArgoApplicationRenderer } from './ArgoApplicationRenderer'
4
4
 
5
+ // SSR splits interpolated text with <!-- --> markers; strip so assertions read as prose.
6
+ function render(element: Parameters<typeof renderToString>[0]): string {
7
+ return renderToString(element).replace(/<!-- -->/g, '')
8
+ }
9
+
10
+ function app(automated: Record<string, unknown> | undefined) {
11
+ return {
12
+ metadata: { name: 'guestbook', namespace: 'argocd' },
13
+ spec: {
14
+ project: 'default',
15
+ source: { repoURL: 'https://github.com/argoproj/argocd-example-apps', path: 'guestbook', targetRevision: 'HEAD' },
16
+ destination: { server: 'https://kubernetes.default.svc', namespace: 'guestbook' },
17
+ syncPolicy: automated ? { automated } : {},
18
+ },
19
+ status: { sync: { status: 'Synced' }, health: { status: 'Healthy' } },
20
+ }
21
+ }
22
+
23
+ function automatedSyncBadge(html: string): string | undefined {
24
+ return html.match(/Automated Sync<\/span>.*?<span class="badge[^"]*">([^<]+)<\/span>/)?.[1]
25
+ }
26
+
27
+ describe('ArgoApplicationRenderer sync policy', () => {
28
+ it('reads an automated block as auto-sync on', () => {
29
+ const html = render(<ArgoApplicationRenderer data={app({ prune: true, selfHeal: true })} />)
30
+ expect(automatedSyncBadge(html)).toBe('Enabled')
31
+ expect(html).toContain('Self Heal')
32
+ })
33
+
34
+ // Argo CD 3.x switches automation off in place: the block and its
35
+ // prune/selfHeal stay, but the controller does not auto-sync.
36
+ it('reads automated.enabled=false as auto-sync off', () => {
37
+ const html = render(<ArgoApplicationRenderer data={app({ enabled: false, prune: true, selfHeal: true })} />)
38
+ expect(automatedSyncBadge(html)).toBe('Disabled')
39
+ expect(html).not.toContain('Self Heal')
40
+ })
41
+
42
+ it('reads a missing automated block as auto-sync off', () => {
43
+ const html = render(<ArgoApplicationRenderer data={app(undefined)} />)
44
+ expect(automatedSyncBadge(html)).toBe('Disabled')
45
+ })
46
+ })
47
+
48
+ // ManagedResourcesList appends the count to its title itself.
49
+ describe('ArgoApplicationRenderer managed resources', () => {
50
+ it('shows the count once', () => {
51
+ const data = app(undefined)
52
+ const html = render(<ArgoApplicationRenderer data={{ ...data, status: { ...data.status, resources: [{ kind: 'Service', name: 'guestbook-ui', namespace: 'guestbook', status: 'Synced' }] } }} />)
53
+ expect(html).toContain('Managed Resources (1)')
54
+ expect(html).not.toContain('(1) (1)')
55
+ })
56
+ })
57
+
5
58
  describe('Argo Helm value files', () => {
6
59
  it.each([true, false, undefined])('shows missing-file behavior for single and multiple sources: %s', optional => {
7
60
  const source = { chart: 'cluster', repoURL: 'https://cloudnative-pg.github.io/charts', helm: { valueFiles: ['values.yaml', 'overrides.yaml'], ignoreMissingValueFiles: optional } }
@@ -1,6 +1,7 @@
1
1
  import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks, ExternalLink } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
4
+ import { isArgoAutoSyncEnabled } from '../resource-utils-argo'
4
5
  import { GitOpsStatusBadge, ManagedResourcesList, SyncCountdown } from '../../gitops'
5
6
  import {
6
7
  argoStatusToGitOpsStatus,
@@ -157,6 +158,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
157
158
  const isMultiSource = Array.isArray(spec.sources) && spec.sources.length > 0
158
159
  const destination = spec.destination || {}
159
160
  const syncPolicy = spec.syncPolicy || {}
161
+ const autoSyncEnabled = isArgoAutoSyncEnabled(data)
160
162
  const operationState = status.operationState
161
163
 
162
164
  // Check if sync is in progress
@@ -192,7 +194,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
192
194
  </div>
193
195
 
194
196
  {/* Sync countdown (only if automated sync is enabled) */}
195
- {syncPolicy.automated && (
197
+ {autoSyncEnabled && (
196
198
  <SyncCountdown
197
199
  interval="5m" // ArgoCD default
198
200
  lastSyncTime={status.reconciledAt}
@@ -241,14 +243,14 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
241
243
  <span
242
244
  className={clsx(
243
245
  'badge',
244
- syncPolicy.automated ? 'status-green' : BADGE_INACTIVE
246
+ autoSyncEnabled ? 'status-green' : BADGE_INACTIVE
245
247
  )}
246
248
  >
247
- {syncPolicy.automated ? 'Enabled' : 'Disabled'}
249
+ {autoSyncEnabled ? 'Enabled' : 'Disabled'}
248
250
  </span>
249
251
  }
250
252
  />
251
- {syncPolicy.automated && (
253
+ {autoSyncEnabled && (
252
254
  <>
253
255
  <Property
254
256
  label="Self Heal"
@@ -374,10 +376,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
374
376
 
375
377
  {/* Managed Resources */}
376
378
  {managedResources.length > 0 && (
377
- <ManagedResourcesList
378
- resources={managedResources}
379
- title={`Managed Resources (${managedResources.length})`}
380
- />
379
+ <ManagedResourcesList resources={managedResources} />
381
380
  )}
382
381
 
383
382
  {/* Revision History */}
@@ -0,0 +1,105 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { afterEach, expect, it, vi } from 'vitest'
5
+ import { CRDSchemaBrowser } from './CRDSchemaBrowser'
6
+ import { resourceToYaml } from '../../../utils/yaml'
7
+
8
+ vi.mock('../../ui/CodeViewer', () => ({ CodeViewer: (props: any) => <pre data-theme={props.theme} data-language={props.language} data-copy={props.showCopyButton}>{props.code}</pre> }))
9
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
10
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
11
+ let root: ReturnType<typeof createRoot>
12
+ let host: HTMLDivElement
13
+ const schema = { type: 'object', 'x-kubernetes-validations': [{ rule: 'has(self.spec)', message: 'Spec is required' }], properties: { spec: {
14
+ type: 'object', description: 'Settings', required: ['name'], 'x-kubernetes-validations': [{ rule: 'self.name != ""', message: 'Choose a name' }], properties: {
15
+ name: { type: 'string', description: 'A display name', minLength: 1, 'x-kubernetes-validations': [{ rule: 'self != "bad"', message: 'Use a good name' }] },
16
+ ports: { type: 'array', items: { properties: { port: { type: 'integer', minimum: 1 } } } },
17
+ },
18
+ } } }
19
+ const data = { metadata: { uid: 'a', name: 'widgets.example.io' }, spec: { names: { kind: 'Widget' }, versions: [
20
+ { name: 'v1', served: true, schema: { openAPIV3Schema: schema } },
21
+ { name: 'v1beta1', served: true, schema: { openAPIV3Schema: { properties: { spec: { properties: { name: { type: 'integer' } } } } } } },
22
+ { name: 'v1alpha1', served: true, schema: { openAPIV3Schema: { properties: { other: { type: 'boolean' } } } } },
23
+ { name: 'v2', served: false },
24
+ ] } }
25
+ afterEach(() => { act(() => root?.unmount()); host?.remove() })
26
+ function mount(resource = data, onCopy?: (text: string) => void) {
27
+ if (!host?.isConnected) { host = document.createElement('div'); document.body.append(host); root = createRoot(host) }
28
+ act(() => root.render(<CRDSchemaBrowser data={resource} theme="light" onCopy={onCopy} />))
29
+ }
30
+ function click(text: string, within: Element = host) {
31
+ const button = Array.from(within.querySelectorAll('button')).find(b => b.textContent === text || b.getAttribute('aria-label') === text)
32
+ expect(button, text).toBeDefined(); act(() => button!.click())
33
+ }
34
+ function filter(value: string) {
35
+ const input = host.querySelector('input')!
36
+ act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })) })
37
+ }
38
+ function version(name: string) {
39
+ act(() => host.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!.click())
40
+ const option = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="option"]')).find(b => b.textContent?.startsWith(name))!
41
+ act(() => option.click())
42
+ }
43
+ const detail = () => host.querySelector('[aria-label="Schema field detail"]')!
44
+ const outline = () => host.querySelector('[aria-label="Schema outline"]')!
45
+
46
+ it('selects independently of expansion, shows facts and parent rules, and follows child fields', () => {
47
+ const copy = vi.fn(); mount(data, copy)
48
+ expect(detail().textContent).toContain('Select a field')
49
+ expect(outline().textContent).not.toContain('ports')
50
+ click('specobject', outline())
51
+ expect(detail().textContent).toContain('Settings')
52
+ expect(detail().textContent).toContain('Choose a name')
53
+ expect(detail().textContent).toContain('Spec is required')
54
+ expect(outline().textContent).not.toContain('ports')
55
+ click('namestringrequired', detail())
56
+ expect(detail().textContent).toContain('spec.name')
57
+ expect(detail().textContent).toContain('minLength: 1')
58
+ expect(detail().textContent).toContain('Use a good name')
59
+ expect(detail().textContent).toContain('Rules on parent fields')
60
+ expect(detail().textContent).toContain('Choose a name')
61
+ click('Copy field path', detail()); expect(copy).toHaveBeenCalledWith('spec.name')
62
+ click('Expand spec', outline())
63
+ expect(outline().querySelector('[aria-current="true"]')?.textContent).toContain('name')
64
+ })
65
+ it('filters pasted paths, selects results and highlights only direct matches', () => {
66
+ mount(); filter('spec.ports[0].port')
67
+ expect(host.textContent).toContain('1 matching field')
68
+ expect(outline().querySelector('mark')).toBeNull()
69
+ click('spec.ports[].portinteger', outline())
70
+ expect(detail().textContent).toContain('spec.ports[].port')
71
+ expect(detail().textContent).toContain('minimum: 1')
72
+ filter('PORTS[]')
73
+ expect(outline().querySelector('mark')?.textContent).toBe('ports[]')
74
+ click('Clear field filter'); expect(host.querySelector('input')?.value).toBe('')
75
+ })
76
+ it('retains selection across versions only while the path exists, and resets on another CRD', () => {
77
+ mount(); filter('spec.name'); click('spec.namestringrequired', outline())
78
+ version('v1beta1')
79
+ expect(detail().textContent).toContain('spec.name')
80
+ expect(detail().textContent).toContain('integer')
81
+ expect(host.querySelector('input')?.value).toBe('spec.name')
82
+ version('v1alpha1'); expect(detail().textContent).toContain('Select a field')
83
+ version('v1'); expect(detail().textContent).toContain('Select a field')
84
+ click('Raw'); mount({ ...data, metadata: { ...data.metadata, uid: 'b' } })
85
+ expect(host.querySelector('input')?.value).toBe('')
86
+ expect(host.querySelector('button[aria-pressed="true"]')?.textContent).toBe('Fields')
87
+ expect(detail().textContent).toContain('Select a field')
88
+ })
89
+ it('shows the version schema as YAML with the existing raw viewer and honest missing-schema copy', () => {
90
+ mount(); click('Raw')
91
+ expect(host.querySelector('input[aria-label="Filter fields"]')).toBeNull()
92
+ const raw = host.querySelector('pre')!
93
+ expect(raw.textContent).toBe(resourceToYaml(schema))
94
+ expect(raw.dataset).toMatchObject({ theme: 'light', language: 'yaml', copy: 'true' })
95
+ version('v2')
96
+ expect(host.textContent).toContain("Radar didn't receive a schema for this version")
97
+ expect(host.querySelector('pre')).toBeNull()
98
+ })
99
+ it('copies a path through the clipboard and handles clipboard rejection', async () => {
100
+ const writeText = vi.fn().mockRejectedValue(new Error('denied'))
101
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
102
+ mount(); click('specobject', outline())
103
+ await act(async () => click('Copy field path', detail()))
104
+ expect(writeText).toHaveBeenCalledWith('spec')
105
+ })