@skyhook-io/k8s-ui 1.19.0 → 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 (139) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  5. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  6. package/src/components/compare/ResourceCompareView.tsx +35 -12
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  10. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  11. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  12. package/src/components/gitops/RollbackDialog.tsx +25 -6
  13. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  14. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  15. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  16. package/src/components/gitops/applicationset-row.test.ts +12 -0
  17. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  18. package/src/components/gitops/detail-helpers.test.ts +8 -2
  19. package/src/components/gitops/detail-helpers.ts +9 -11
  20. package/src/components/gitops/index.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
  23. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  24. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
  26. package/src/components/gitops/insights/insights-helpers.ts +12 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  28. package/src/components/issues/diagnostic.ts +14 -1
  29. package/src/components/issues/severity.ts +14 -0
  30. package/src/components/kueue/AdmissionPath.tsx +223 -0
  31. package/src/components/kueue/QueueEvidence.tsx +148 -0
  32. package/src/components/kueue/constants.ts +1 -0
  33. package/src/components/kueue/eligibility.ts +43 -0
  34. package/src/components/kueue/flavors-tried.ts +79 -0
  35. package/src/components/kueue/helpers.test.ts +105 -0
  36. package/src/components/kueue/policy.ts +100 -0
  37. package/src/components/kueue/queue-detail.test.tsx +317 -0
  38. package/src/components/kueue/queue-status.ts +42 -0
  39. package/src/components/kueue/related-read.ts +48 -0
  40. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  41. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  42. package/src/components/logs/StructuredLogLine.tsx +8 -3
  43. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  44. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  45. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  46. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  47. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  48. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  49. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  50. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  51. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  52. package/src/components/resources/ResourcesView.tsx +50 -3
  53. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  54. package/src/components/resources/generic-status.ts +7 -0
  55. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  56. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  57. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  58. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  59. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  60. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  61. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  62. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  63. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  64. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  65. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  66. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  67. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  70. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  71. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  72. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  73. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  74. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  75. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  76. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  77. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  78. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  79. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  80. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  81. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  82. package/src/components/resources/renderers/index.ts +6 -0
  83. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  84. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  85. package/src/components/resources/resource-utils-argo.ts +84 -5
  86. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  87. package/src/components/resources/resource-utils-crd.ts +26 -0
  88. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  89. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  90. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  91. package/src/components/resources/resource-utils-kueue.ts +137 -4
  92. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  93. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  94. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  95. package/src/components/resources/resource-utils-ray.ts +38 -0
  96. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  97. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  98. package/src/components/resources/resource-utils.ts +45 -1
  99. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  100. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  101. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  102. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  103. package/src/components/topology/K8sResourceNode.tsx +18 -1
  104. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  105. package/src/components/topology/TopologyGraph.tsx +96 -94
  106. package/src/components/topology/layout.ts +3 -1
  107. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  108. package/src/components/ui/Badge.tsx +1 -0
  109. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  110. package/src/components/ui/drawer-components.tsx +1 -1
  111. package/src/components/ui/index.ts +1 -0
  112. package/src/components/workload/PodList.tsx +35 -9
  113. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  114. package/src/components/workload/WorkloadView.tsx +61 -8
  115. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  116. package/src/types/core.ts +40 -0
  117. package/src/types/gitops-insights.ts +11 -2
  118. package/src/types/gitops-tree.ts +2 -2
  119. package/src/types/kueue-queues.ts +84 -0
  120. package/src/types/read-coverage.ts +2 -1
  121. package/src/types/scheduling.ts +72 -0
  122. package/src/utils/animation.test.ts +38 -0
  123. package/src/utils/animation.ts +34 -0
  124. package/src/utils/api-resources-crd.test.ts +11 -0
  125. package/src/utils/api-resources.ts +5 -1
  126. package/src/utils/applications.test.ts +15 -1
  127. package/src/utils/applications.ts +26 -1
  128. package/src/utils/crd-schema.test.ts +107 -0
  129. package/src/utils/crd-schema.ts +201 -0
  130. package/src/utils/format.test.ts +37 -1
  131. package/src/utils/format.ts +28 -80
  132. package/src/utils/gitops-owner.test.ts +28 -2
  133. package/src/utils/gitops-owner.ts +19 -0
  134. package/src/utils/kueue-queue-workloads.ts +19 -0
  135. package/src/utils/resource-icons.ts +2 -0
  136. package/src/utils/topology-neighborhood.test.ts +90 -1
  137. package/src/utils/topology-neighborhood.ts +55 -3
  138. package/src/utils/workload-history-notes.test.ts +76 -0
  139. package/src/utils/workload-history-notes.ts +93 -0
@@ -0,0 +1,30 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { expect, it } from 'vitest'
4
+ import { CRDVersionsCell } from './crd-cells'
5
+
6
+ it('sorts served versions, shows two complete tokens and summarizes the rest', () => {
7
+ const container = document.createElement('div')
8
+ container.innerHTML = renderToStaticMarkup(<CRDVersionsCell versions={[
9
+ { name: 'v2alpha1', served: true, deprecated: true }, { name: 'v1beta1', served: true },
10
+ { name: 'v1', served: true, storage: true }, { name: 'v2', served: true }, { name: 'v3', served: false },
11
+ ]} />)
12
+ expect(Array.from(container.querySelectorAll('.font-mono')).map(el => el.textContent)).toEqual(['v2', 'v1'])
13
+ expect(container.textContent).toBe('v2v1+2')
14
+ expect(container.querySelector('[class*="overflow-hidden"]')).toBeNull()
15
+ for (const token of container.querySelectorAll('.font-mono')) {
16
+ expect(token.parentElement?.classList.contains('shrink-0')).toBe(true)
17
+ expect(token.parentElement?.classList.contains('whitespace-nowrap')).toBe(true)
18
+ }
19
+ container.innerHTML = renderToStaticMarkup(<CRDVersionsCell versions={[
20
+ { name: 'v1beta2', served: true }, { name: 'v1beta1', served: true, deprecated: true },
21
+ ]} />)
22
+ expect(container.textContent).toBe('v1beta2v1beta1deprecated')
23
+ container.innerHTML = renderToStaticMarkup(<CRDVersionsCell versions={[
24
+ { name: 'v1beta2', served: true, deprecated: true }, { name: 'v1beta1', served: true, deprecated: true },
25
+ { name: 'v1alpha1', served: true },
26
+ ]} />)
27
+ expect(container.textContent).toBe('v1beta2deprecated+2')
28
+ container.innerHTML = renderToStaticMarkup(<CRDVersionsCell versions={[]} />)
29
+ expect(container.textContent).toBe('None served')
30
+ })
@@ -0,0 +1,55 @@
1
+ import { Badge } from '../../ui/Badge'
2
+ import { Tooltip } from '../../ui/Tooltip'
3
+ import { compareKubeAwareVersions, type CRDVersion } from '../../../utils/crd-schema'
4
+
5
+ export function CRDVersionLabel({ version, showStorage = true }: { version: CRDVersion; showStorage?: boolean }) {
6
+ return <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap">
7
+ <span className="font-mono">{version.name}</span>
8
+ {showStorage && version.storage && <Tooltip content="Objects are saved in this version; the API server converts them to the others on read">
9
+ <Badge tone="structural" size="sm">storage</Badge>
10
+ </Tooltip>}
11
+ {version.deprecated && <Tooltip content={version.deprecationWarning || 'Requests for this version return a deprecation warning'}>
12
+ <Badge severity="warning" size="sm">deprecated</Badge>
13
+ </Tooltip>}
14
+ {!version.served && <Tooltip content="The API server doesn't serve this version, so clients can't read or write it">
15
+ <Badge severity="neutral" size="sm">not served</Badge>
16
+ </Tooltip>}
17
+ </span>
18
+ }
19
+
20
+ // Rendered widths (px) measured in the list: a version name character, the
21
+ // deprecated badge with its gap, the gap between tokens, the "+N" count, and the
22
+ // Served Versions column's content width (w-64 less its padding).
23
+ const VERSION_CHAR_PX = 8.5
24
+ const DEPRECATED_BADGE_PX = 80
25
+ const TOKEN_GAP_PX = 8
26
+ const COUNT_PX = 24
27
+ const VERSIONS_CELL_CONTENT_PX = 224
28
+
29
+ function versionTokenWidth(version: CRDVersion): number {
30
+ return version.name.length * VERSION_CHAR_PX + (version.deprecated ? DEPRECATED_BADGE_PX : 0)
31
+ }
32
+
33
+ // One line per row and never a partial version: storage is left to the drawer,
34
+ // and versions that would not fit whole collapse into a count.
35
+ export function CRDVersionsCell({ versions }: { versions: CRDVersion[] }) {
36
+ const served = versions.filter(v => v.served).sort((a, b) => compareKubeAwareVersions(a.name, b.name))
37
+ if (served.length === 0) return <span className="text-sm text-theme-text-tertiary">None served</span>
38
+ let used = 0
39
+ let shown = 0
40
+ for (const version of served.slice(0, 2)) {
41
+ const width = versionTokenWidth(version) + (shown ? TOKEN_GAP_PX : 0)
42
+ const hiddenAfter = served.length - shown - 1
43
+ const budget = VERSIONS_CELL_CONTENT_PX - (hiddenAfter > 0 ? COUNT_PX + TOKEN_GAP_PX : 0)
44
+ if (shown && used + width > budget) break
45
+ used += width
46
+ shown++
47
+ }
48
+ const rest = served.slice(shown)
49
+ return <div className="flex items-center gap-2 whitespace-nowrap text-sm text-theme-text-secondary">
50
+ {served.slice(0, shown).map(v => <CRDVersionLabel key={v.name} version={v} showStorage={false} />)}
51
+ {rest.length > 0 && <Tooltip wrapperClassName="shrink-0 whitespace-nowrap" content={rest.map(v => [v.name, v.storage && 'storage', v.deprecated && (v.deprecationWarning ? `deprecated: ${v.deprecationWarning}` : 'deprecated')].filter(Boolean).join(' · ')).join('; ')}>
52
+ <span className="shrink-0 whitespace-nowrap">+{rest.length}</span>
53
+ </Tooltip>}
54
+ </div>
55
+ }
@@ -0,0 +1,59 @@
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 { ParentRulesList, SchemaFacts, SchemaPathText } from './crd-schema-parts'
6
+ import { indexCRDSchema } from '../../../utils/crd-schema'
7
+
8
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
9
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
10
+ let root: ReturnType<typeof createRoot>
11
+ let host: HTMLDivElement
12
+ afterEach(() => { act(() => root?.unmount()); host?.remove() })
13
+ function mount(content: React.ReactNode) {
14
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
15
+ act(() => root.render(content))
16
+ }
17
+ it('renders messages before their expressions, including parent and root rules, and discloses grouped rules', () => {
18
+ const schema = { 'x-kubernetes-validations': [{ rule: 'has(self.spec)', message: 'Spec must exist' }], properties: { spec: {
19
+ 'x-kubernetes-validations': Array.from({ length: 5 }, (_, i) => ({ rule: `self.x > ${i}`, message: `Message ${i}` })), properties: { x: { type: 'integer' } },
20
+ } } }
21
+ const node = indexCRDSchema(schema).find(node => node.path.length === 2)!
22
+ mount(<><SchemaFacts schema={{ 'x-kubernetes-validations': [{ rule: 'self > 0', message: 'Positive value' }, { rule: 'self < 10' }] }} /><ParentRulesList node={node} root={schema} /></>)
23
+ const own = host.querySelector('.card-inner')!
24
+ expect(own.firstElementChild?.textContent).toBe('Positive value')
25
+ expect(own.lastElementChild?.textContent).toBe('self > 0')
26
+ expect(host.textContent).toContain('5 rules on spec')
27
+ expect(host.textContent).toContain('1 rule on Document root')
28
+ const rootGroup = Array.from(host.querySelectorAll('button')).find(b => b.textContent?.includes('1 rule on Document root'))!
29
+ expect(rootGroup.getAttribute('aria-expanded')).toBe('false')
30
+ expect(host.textContent).not.toContain('Spec must exist')
31
+ act(() => rootGroup.click())
32
+ expect(host.textContent).toContain('Spec must exist')
33
+ expect(host.textContent).not.toContain('Message 3')
34
+ const button = Array.from(host.querySelectorAll('button')).find(b => b.textContent === 'Show 2 more')!
35
+ expect(button.getAttribute('aria-expanded')).toBe('false')
36
+ act(() => button.click())
37
+ expect(button.getAttribute('aria-expanded')).toBe('true')
38
+ expect(host.textContent).toContain('Message 4')
39
+ for (const card of host.querySelectorAll('.card-inner')) {
40
+ if (card.children.length === 2) expect(card.firstElementChild?.tagName).toBe('P')
41
+ }
42
+ })
43
+ it('uses labeled code blocks for long constraints and structured defaults while short scalars remain badges', () => {
44
+ mount(<SchemaFacts schema={{ default: { x: 1 }, pattern: 'x'.repeat(41), minimum: 0 }} />)
45
+ expect(Array.from(host.querySelectorAll('pre')).map(p => p.textContent)).toEqual(['{"x":1}', 'x'.repeat(41)])
46
+ expect(Array.from(host.querySelectorAll('pre')).map(p => p.previousElementSibling?.textContent)).toEqual(['default', 'pattern'])
47
+ expect(host.querySelector('.badge-sm')?.textContent).toContain('minimum: 0')
48
+ })
49
+ it('highlights only direct case-insensitive substrings of the displayed path', () => {
50
+ const node = indexCRDSchema({ properties: { ports: { items: { properties: { name: { type: 'string' } } } } } })[1]
51
+ const both = (query: string) => <><SchemaPathText path={node.path} query={query} part="parent" /><SchemaPathText path={node.path} query={query} part="leaf" /></>
52
+ mount(both('PORTS[]'))
53
+ expect(host.querySelector('mark')?.textContent).toBe('ports[]')
54
+ act(() => root.render(both('nam')))
55
+ expect(host.querySelector('mark')?.textContent).toBe('nam')
56
+ act(() => root.render(both('ports[0].name')))
57
+ expect(host.querySelector('mark')).toBeNull()
58
+ expect(host.textContent).toBe('ports[].name')
59
+ })
@@ -0,0 +1,121 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { Search, X } from 'lucide-react'
3
+ import { Badge } from '../../ui/Badge'
4
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
+ import { Input } from '../../ui/Input'
6
+ import {
7
+ displaySchemaPath, enclosingRules, schemaCompositions, schemaConstraints, schemaElement, schemaParentPath,
8
+ type CRDSchema, type EnclosingRules, type SchemaNode, type SchemaPath,
9
+ } from '../../../utils/crd-schema'
10
+
11
+ export function RequiredMarker() {
12
+ return <span className="text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">required</span>
13
+ }
14
+
15
+ export function SchemaFieldFilter({ value, onChange }: { value: string; onChange: (value: string) => void }) {
16
+ return <div className="relative min-w-0 flex-1">
17
+ <Search aria-hidden="true" className="pointer-events-none absolute left-2.5 top-2 h-4 w-4 text-theme-text-tertiary" />
18
+ <Input aria-label="Filter fields" placeholder="Find a field or paste an error path" value={value} onChange={e => onChange(e.target.value)} className="h-8 w-full min-w-0 rounded-md border border-theme-border bg-theme-elevated pl-8 pr-8 text-xs text-theme-text-primary placeholder:text-theme-text-tertiary focus:border-accent focus:outline-none" />
19
+ {value && <button type="button" aria-label="Clear field filter" onClick={() => onChange('')} className="absolute right-2 top-2 text-theme-text-tertiary hover:text-theme-text-primary"><X className="h-4 w-4" /></button>}
20
+ </div>
21
+ }
22
+
23
+ // Renders text[from, to) with the matched range marked and a break opportunity
24
+ // after each `.`, so long paths wrap between segments instead of mid-name.
25
+ function PathPiece({ text, from, to, match }: { text: string; from: number; to: number; match: [number, number] | null }) {
26
+ const parts: ReactNode[] = []
27
+ const pushPlain = (start: number, end: number) => {
28
+ text.slice(start, end).split(/(?<=\.)/).forEach((piece, i, all) => {
29
+ parts.push(<span key={`${start}-${i}`}>{piece}</span>)
30
+ if (piece.endsWith('.') && i < all.length - 1) parts.push(<wbr key={`${start}-${i}-w`} />)
31
+ })
32
+ }
33
+ const [ms, me] = match ? [Math.max(match[0], from), Math.min(match[1], to)] : [0, 0]
34
+ if (!match || ms >= me) { pushPlain(from, to); return <>{parts}</> }
35
+ pushPlain(from, ms)
36
+ parts.push(<mark key="m" className="rounded-sm bg-accent-muted text-theme-text-primary">{text.slice(ms, me)}</mark>)
37
+ pushPlain(me, to)
38
+ return <>{parts}</>
39
+ }
40
+
41
+ /** One part of a search hit's path, with the query's match marked across the
42
+ * parent/field boundary: the muted parent path, or the field name itself. */
43
+ export function SchemaPathText({ path, query = '', part }: { path: SchemaPath; query?: string; part: 'parent' | 'leaf' }) {
44
+ const text = displaySchemaPath(path)
45
+ const parentLength = schemaParentPath(path).length
46
+ const term = query.trim().toLowerCase()
47
+ const at = term ? text.toLowerCase().indexOf(term) : -1
48
+ const match: [number, number] | null = at < 0 ? null : [at, at + term.length]
49
+ if (part === 'parent') {
50
+ return parentLength > 0 ? <span className="block font-mono text-xs text-theme-text-tertiary [overflow-wrap:anywhere]"><PathPiece text={text} from={0} to={parentLength} match={match} /></span> : null
51
+ }
52
+ return <PathPiece text={text} from={parentLength} to={text.length} match={match} />
53
+ }
54
+
55
+ export function SchemaRules({ rules }: { rules: NonNullable<CRDSchema['x-kubernetes-validations']> }) {
56
+ return <>{rules.map((validation, i) => <div key={i} className="card-inner space-y-1">
57
+ {validation.message && <p className="text-sm text-theme-text-secondary">{validation.message}</p>}
58
+ <div className={`whitespace-pre-wrap break-words font-mono text-xs ${validation.message ? 'text-theme-text-tertiary' : 'text-theme-text-secondary'}`}>{validation.rule}</div>
59
+ </div>)}</>
60
+ }
61
+
62
+ function ConstraintDisplay({ label, value, block }: { label: string; value: string; block: boolean }) {
63
+ return block ? <div className="min-w-0 w-full space-y-1">
64
+ <p className="text-xs text-theme-text-secondary">{label}</p>
65
+ <pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words rounded bg-theme-elevated p-3 font-mono text-xs text-theme-text-secondary">{value}</pre>
66
+ </div> : <Badge tone="structural" size="sm">{label}: {value}</Badge>
67
+ }
68
+
69
+ export function SchemaFacts({ schema, elementOf, parentDescription, showDescription = true }: { schema: CRDSchema; elementOf?: string; parentDescription?: string; showDescription?: boolean }) {
70
+ const element = schemaElement(schema)
71
+ const prefix = elementOf ? `${elementOf} ` : ''
72
+ return <>
73
+ {showDescription && schema.description && schema.description !== parentDescription && <p className="whitespace-pre-wrap">{elementOf ? `Each entry (${elementOf}): ${schema.description}` : schema.description}</p>}
74
+ <div className="flex flex-wrap gap-1">{schemaConstraints(schema).map(fact => <ConstraintDisplay key={fact.label} label={`${prefix}${fact.label}`} value={fact.value} block={fact.value.length > 40 || (fact.label === 'default' && schema.default !== null && typeof schema.default === 'object')} />)}</div>
75
+ {schema['x-kubernetes-embedded-resource'] && <p>{elementOf ? 'Each entry is' : 'Holds'} a Kubernetes object: apiVersion, kind and metadata are implied.</p>}
76
+ {schema['x-kubernetes-preserve-unknown-fields'] && <p>{elementOf ? 'Each entry accepts' : 'Accepts'} fields not declared in this schema.</p>}
77
+ {schema['x-kubernetes-int-or-string'] && <p>{elementOf ? 'Each entry is' : 'Accepts'} an integer or a string.</p>}
78
+ <SchemaRules rules={schema['x-kubernetes-validations'] ?? []} />
79
+ {schemaCompositions(schema).map(composition => <div key={composition.label}>
80
+ <span>{prefix}{composition.label}: </span><code className="break-words whitespace-pre-wrap">{composition.value}</code>
81
+ </div>)}
82
+ {element && <SchemaFacts schema={element.schema} elementOf={`${prefix}${element.label}`} parentDescription={schema.description ?? parentDescription} />}
83
+ </>
84
+ }
85
+
86
+ function ParentRuleGroup({ group, defaultOpen }: { group: EnclosingRules; defaultOpen: boolean }) {
87
+ const [groupOpen, setGroupOpen] = useState(defaultOpen)
88
+ const [moreOpen, setMoreOpen] = useState(false)
89
+ const groupDisclosure = useDisclosure(groupOpen)
90
+ const moreDisclosure = useDisclosure(moreOpen)
91
+ const count = group.validations.length + group.compositions.length
92
+ const rules = [
93
+ ...group.validations.map((rule, i) => <SchemaRules key={`v${i}`} rules={[rule]} />),
94
+ ...group.compositions.map(composition => <div key={composition.label}><span>{composition.label}: </span><code className="whitespace-pre-wrap break-words">{composition.value}</code></div>),
95
+ ]
96
+ return <div className="space-y-1">
97
+ <button type="button" {...groupDisclosure.buttonProps} onClick={() => setGroupOpen(v => !v)} className="flex items-start gap-1 text-left text-xs text-theme-text-tertiary hover:text-theme-text-secondary">
98
+ <CollapseChevron open={groupOpen} className="mt-0.5 h-3 w-3 shrink-0" />
99
+ <span className="break-words">{count} {count === 1 ? 'rule' : 'rules'} on <span className="font-mono">{group.path || 'Document root'}</span></span>
100
+ </button>
101
+ <Collapse open={groupOpen} id={groupDisclosure.panelId} mountLazily>
102
+ <div className="space-y-1">
103
+ {rules.slice(0, 3)}
104
+ {count > 3 && <>
105
+ <button type="button" {...moreDisclosure.buttonProps} onClick={() => setMoreOpen(v => !v)} className="text-xs text-accent">{moreOpen ? 'Show fewer' : `Show ${count - 3} more`}</button>
106
+ <Collapse open={moreOpen} id={moreDisclosure.panelId} mountLazily><div className="space-y-1">{rules.slice(3)}</div></Collapse>
107
+ </>}
108
+ </div>
109
+ </Collapse>
110
+ </div>
111
+ }
112
+
113
+ // The nearest parent's rules are the likeliest to concern the field; farther
114
+ // groups start collapsed to their path and count.
115
+ export function ParentRulesList({ node, root }: { node: SchemaNode; root: CRDSchema }) {
116
+ const groups = enclosingRules(node, root)
117
+ return groups.length ? <div className="space-y-2">
118
+ <p className="text-xs font-medium text-theme-text-tertiary">Rules on parent fields</p>
119
+ {groups.map((group, i) => <ParentRuleGroup key={group.path} group={group} defaultOpen={i === 0} />)}
120
+ </div> : null
121
+ }
@@ -195,3 +195,9 @@ export { RayJobRenderer } from './RayJobRenderer'
195
195
 
196
196
  export { OpenTelemetryCollectorRenderer, OpenTelemetryInstrumentationRenderer } from './OpenTelemetryRenderers'
197
197
  export { FlaggerCanaryRenderer, FlaggerMetricTemplateRenderer } from './FlaggerRenderers'
198
+
199
+ export { StrimziConnectorRenderer } from './StrimziConnectorRenderer'
200
+
201
+ export { CustomResourceDefinitionRenderer } from './CustomResourceDefinitionRenderer'
202
+
203
+ export { CRDSchemaBrowser, type CRDSchemaBrowserProps } from './CRDSchemaBrowser'
@@ -6,6 +6,7 @@ import {
6
6
  getClusterQueueCohort,
7
7
  getClusterQueuePendingWorkloads,
8
8
  getClusterQueueAdmittedWorkloads,
9
+ getKueueQueueReservingWorkloads,
9
10
  getClusterQueueFlavors,
10
11
  getLocalQueueStatus,
11
12
  getLocalQueueClusterQueue,
@@ -42,6 +43,8 @@ export function ClusterQueueCell({ resource, column }: { resource: any; column:
42
43
  const pending = getClusterQueuePendingWorkloads(resource)
43
44
  return <span className="text-sm text-theme-text-secondary">{pending}</span>
44
45
  }
46
+ case 'reservingWorkloads':
47
+ return <span className="text-sm text-theme-text-secondary">{getKueueQueueReservingWorkloads(resource)}</span>
45
48
  case 'admittedWorkloads': {
46
49
  const admitted = getClusterQueueAdmittedWorkloads(resource)
47
50
  return <span className="text-sm text-theme-text-secondary">{admitted}</span>
@@ -73,6 +76,8 @@ export function LocalQueueCell({ resource, column }: { resource: any; column: st
73
76
  const pending = getLocalQueuePendingWorkloads(resource)
74
77
  return <span className="text-sm text-theme-text-secondary">{pending}</span>
75
78
  }
79
+ case 'reservingWorkloads':
80
+ return <span className="text-sm text-theme-text-secondary">{getKueueQueueReservingWorkloads(resource)}</span>
76
81
  case 'admittedWorkloads': {
77
82
  const admitted = getLocalQueueAdmittedWorkloads(resource)
78
83
  return <span className="text-sm text-theme-text-secondary">{admitted}</span>
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { getArgoApplicationStatus, isArgoOperationInProgress } from './resource-utils-argo'
2
+ import { argoComparedRevisionCurrent, argoResumeSettings, getArgoApplicationStatus, isArgoAutoSyncEnabled, isArgoAutoSyncSwitchedOff, isArgoOperationInProgress } from './resource-utils-argo'
3
3
 
4
4
  describe('getArgoApplicationStatus', () => {
5
5
  // A Suspended Argo app is intentionally paused — neutral (sky), matching the
@@ -21,6 +21,16 @@ describe('getArgoApplicationStatus', () => {
21
21
  expect(badge.level).toBe('unhealthy')
22
22
  })
23
23
 
24
+ // Radar's annotations plus automation switched off in place still read as
25
+ // suspended; the same annotations on an app that auto-syncs again do not.
26
+ it('treats a Radar-suspended app with automated.enabled=false as suspended', () => {
27
+ const annotations = { 'radarhq.io/suspended-prune': 'true' }
28
+ const off = { metadata: { annotations }, spec: { syncPolicy: { automated: { enabled: false } } }, status: {} }
29
+ const on = { metadata: { annotations }, spec: { syncPolicy: { automated: { prune: true } } }, status: {} }
30
+ expect(getArgoApplicationStatus(off).text).toBe('Suspended')
31
+ expect(getArgoApplicationStatus(on).text).not.toBe('Suspended')
32
+ })
33
+
24
34
  it('surfaces an operation being terminated', () => {
25
35
  const badge = getArgoApplicationStatus({ status: { operationState: { phase: 'Terminating' } } })
26
36
  expect(badge.level).toBe('degraded')
@@ -38,3 +48,86 @@ describe('isArgoOperationInProgress', () => {
38
48
  expect(isArgoOperationInProgress(app)).toBe(expected)
39
49
  })
40
50
  })
51
+
52
+ // Mirrors gitops.ArgoAutoSyncEnabled and Argo's SyncPolicy.IsAutomatedSyncEnabled:
53
+ // Argo CD 3.x keeps the automated block (and its prune/selfHeal) when
54
+ // automation is switched off with enabled: false.
55
+ describe('isArgoAutoSyncEnabled', () => {
56
+ const app = (syncPolicy: unknown) => ({ spec: { syncPolicy } })
57
+ it.each([
58
+ ['no sync policy', undefined, false],
59
+ ['no automated block', {}, false],
60
+ ['automated: null', { automated: null }, false],
61
+ ['empty automated block', { automated: {} }, true],
62
+ ['automated with prune/selfHeal', { automated: { prune: true, selfHeal: true } }, true],
63
+ ['enabled: true', { automated: { enabled: true } }, true],
64
+ ['enabled: false', { automated: { enabled: false, prune: true, selfHeal: true } }, false],
65
+ ])('%s', (_label, syncPolicy, expected) => {
66
+ expect(isArgoAutoSyncEnabled(app(syncPolicy))).toBe(expected)
67
+ })
68
+
69
+ it('tolerates a missing resource', () => {
70
+ expect(isArgoAutoSyncEnabled(undefined)).toBe(false)
71
+ })
72
+ })
73
+
74
+ describe('isArgoAutoSyncSwitchedOff', () => {
75
+ const app = (syncPolicy: unknown) => ({ spec: { syncPolicy } })
76
+ // Only an automated block kept with enabled: false; a Manual app has none.
77
+ it.each([
78
+ ['no automated block', {}, false],
79
+ ['automated: null', { automated: null }, false],
80
+ ['automated on', { automated: { prune: true } }, false],
81
+ ['enabled: true', { automated: { enabled: true } }, false],
82
+ ['enabled: false', { automated: { enabled: false, prune: true } }, true],
83
+ ])('%s', (_label, syncPolicy, expected) => {
84
+ expect(isArgoAutoSyncSwitchedOff(app(syncPolicy))).toBe(expected)
85
+ })
86
+
87
+ it('tolerates a missing resource', () => {
88
+ expect(isArgoAutoSyncSwitchedOff(undefined)).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('argoResumeSettings', () => {
93
+ const app = (syncPolicy: unknown) => ({ spec: { syncPolicy } })
94
+ it('keeps a block switched off in place, whatever the Radar', () => {
95
+ expect(argoResumeSettings(app({ automated: { enabled: false, prune: true } }), undefined)).toEqual({ prune: true, selfHeal: false })
96
+ })
97
+ it.each([
98
+ ['a Radar starting both off', true, true, { prune: false, selfHeal: false }],
99
+ ['a Radar starting only prune off', true, false, { prune: false, selfHeal: true }],
100
+ ['a host that passes no self-heal answer', true, undefined, { prune: false, selfHeal: true }],
101
+ ['an older Radar', false, false, { prune: true, selfHeal: true }],
102
+ ['an unknown Radar', undefined, undefined, null],
103
+ ])('without a block, follows %s', (_label, serverStartsPruneOff, serverStartsSelfHealOff, expected) => {
104
+ expect(argoResumeSettings(app({}), serverStartsPruneOff, serverStartsSelfHealOff)).toEqual(expected)
105
+ })
106
+ })
107
+
108
+ // Mirrors the backend's pin rule (pkg/gitops argoSyncOperation), so the sync
109
+ // dialog names the compared revision only when a sync will use it.
110
+ describe('argoComparedRevisionCurrent', () => {
111
+ const source = { repoURL: 'https://example.com/repo', path: 'app', targetRevision: 'main' }
112
+ const values = { repoURL: 'https://example.com/values', ref: 'values', targetRevision: 'main' }
113
+ const single = (sync: any, spec: any = { source }) => ({ spec, status: { sync } })
114
+
115
+ it('is current when the comparison was for the same source, whatever its key order', () => {
116
+ expect(argoComparedRevisionCurrent(single({ revision: 'sha', comparedTo: { source: { targetRevision: 'main', path: 'app', repoURL: 'https://example.com/repo' } } }))).toBe(true)
117
+ })
118
+ it('is not current after the source changed, or without a compared revision', () => {
119
+ expect(argoComparedRevisionCurrent(single({ revision: 'sha', comparedTo: { source: { ...source, targetRevision: 'release' } } }))).toBe(false)
120
+ expect(argoComparedRevisionCurrent(single({ comparedTo: { source } }))).toBe(false)
121
+ expect(argoComparedRevisionCurrent(single(undefined))).toBe(false)
122
+ })
123
+ it('needs every source compared, one revision each, for a multi-source Application', () => {
124
+ const spec = { sources: [source, values] }
125
+ expect(argoComparedRevisionCurrent(single({ revisions: ['a', 'b'], comparedTo: { sources: [source, values] } }, spec))).toBe(true)
126
+ expect(argoComparedRevisionCurrent(single({ revisions: ['a'], comparedTo: { sources: [source, values] } }, spec))).toBe(false)
127
+ expect(argoComparedRevisionCurrent(single({ revisions: ['a', 'b'], comparedTo: { sources: [values, source] } }, spec))).toBe(false)
128
+ })
129
+ it('is never current for a hydrated Application', () => {
130
+ const spec = { sourceHydrator: { drySource: source, syncSource: { targetBranch: 'env/prod', path: 'out' } }, source }
131
+ expect(argoComparedRevisionCurrent(single({ revision: 'sha', comparedTo: { source } }, spec))).toBe(false)
132
+ })
133
+ })
@@ -12,16 +12,15 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
12
12
  const sync = app.status?.sync?.status
13
13
  const opPhase = app.status?.operationState?.phase
14
14
 
15
- // Check for suspended (no automated sync policy). Honor both the current
15
+ // Check for suspended (auto-sync off). Honor both the current
16
16
  // "radarhq.io/suspended-prune" annotation and the legacy "skyhook.io/..."
17
17
  // key still present on Applications suspended by older Radar builds.
18
18
  // The annotation value stores the prior prune state as "true"/"false" for
19
19
  // restore on resume — both strings are truthy in JS, which is intentional:
20
20
  // the *presence* of the annotation is what signals suspended, not its value.
21
- const hasAutomatedSync = !!app.spec?.syncPolicy?.automated
22
21
  const annotations = app.metadata?.annotations
23
22
  const suspendedByRadar = annotations?.['radarhq.io/suspended-prune'] || annotations?.['skyhook.io/suspended-prune']
24
- if (health === 'Suspended' || (!hasAutomatedSync && suspendedByRadar)) {
23
+ if (health === 'Suspended' || (!isArgoAutoSyncEnabled(app) && suspendedByRadar)) {
25
24
  // Suspended = an operator deliberately paused this app — intentional, not a
26
25
  // degradation. Neutral (sky), matching the backend rollup (mapArgoHealth) so a
27
26
  // suspended app reads the same Idle tone in Applications, the resource table,
@@ -64,14 +63,94 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
64
63
  return { text: health || sync || 'Unknown', color: healthColors.unknown, level: 'unknown' }
65
64
  }
66
65
 
66
+ // isArgoAutoSyncEnabled reports whether an Argo CD Application auto-syncs.
67
+ // Argo CD 3.x can keep spec.syncPolicy.automated, with its prune and selfHeal
68
+ // settings, while switching automation off with `enabled: false`, so the block
69
+ // being present is not enough: only an absent or true `enabled` counts. Same
70
+ // rule as Argo's SyncPolicy.IsAutomatedSyncEnabled and the backend's
71
+ // gitops.ArgoAutoSyncEnabled, so the UI and the rollback gate agree.
72
+ export function isArgoAutoSyncEnabled(app: any): boolean {
73
+ const automated = app?.spec?.syncPolicy?.automated
74
+ if (automated == null || typeof automated !== 'object') return false
75
+ return automated.enabled !== false
76
+ }
77
+
78
+ // isArgoAutoSyncSwitchedOff reports an automated block kept with
79
+ // `enabled: false`. Only a Radar advertising `argoRollbackSync` turns such an
80
+ // app's auto-sync back on; an older one's resume leaves `enabled: false` set.
81
+ export function isArgoAutoSyncSwitchedOff(app: any): boolean {
82
+ const automated = app?.spec?.syncPolicy?.automated
83
+ return automated != null && typeof automated === 'object' && automated.enabled === false
84
+ }
85
+
86
+ // argoResumeSettings says what Enable auto-sync turns on for an Application
87
+ // Radar didn't suspend. A block switched off in place keeps its own prune and
88
+ // selfHeal. Without a block it depends on the Radar serving the request: one
89
+ // advertising argoSyncInheritance starts prune off, and one advertising
90
+ // argoEnableSelfHealOff starts selfHeal off; an older one turns each on. Null
91
+ // while either isn't known.
92
+ export function argoResumeSettings(app: any, serverStartsPruneOff: boolean | undefined, serverStartsSelfHealOff?: boolean): { prune: boolean; selfHeal: boolean } | null {
93
+ const automated = app?.spec?.syncPolicy?.automated
94
+ if (automated != null && typeof automated === 'object') {
95
+ return { prune: automated.prune === true, selfHeal: automated.selfHeal === true }
96
+ }
97
+ if (serverStartsPruneOff === undefined) return null
98
+ return { prune: !serverStartsPruneOff, selfHeal: !serverStartsSelfHealOff }
99
+ }
100
+
101
+ // isArgoHydrated: spec.sourceHydrator sets the Application's source, taking
102
+ // precedence over spec.source and spec.sources.
103
+ function isArgoHydrated(app: any): boolean {
104
+ return app?.spec?.sourceHydrator != null
105
+ }
106
+
107
+ // isArgoMultiSource: the Application syncs several sources, so a single
108
+ // revision override doesn't apply. Leftover spec.sources beside a hydrator don't count.
109
+ export function isArgoMultiSource(app: any): boolean {
110
+ return !isArgoHydrated(app) && Array.isArray(app?.spec?.sources) && app.spec.sources.length > 0
111
+ }
112
+
113
+ // argoComparedRevisionCurrent mirrors the rule a Radar advertising
114
+ // argoSyncInheritance applies to a sync without a revision: it uses the last
115
+ // compared revision(s) only when the comparison was for the Application's
116
+ // current source(s), one revision per source. Hydrated Applications are
117
+ // never pinned.
118
+ export function argoComparedRevisionCurrent(app: any): boolean {
119
+ if (isArgoHydrated(app)) return false
120
+ const sync = app?.status?.sync
121
+ if (isArgoMultiSource(app)) {
122
+ const sources = app.spec.sources
123
+ return Array.isArray(sync?.revisions) && sync.revisions.length === sources.length && jsonEqual(sources, sync?.comparedTo?.sources)
124
+ }
125
+ const source = app?.spec?.source
126
+ return !!source && Object.keys(source).length > 0 && !!sync?.revision && jsonEqual(source, sync?.comparedTo?.source)
127
+ }
128
+
129
+ function jsonEqual(a: unknown, b: unknown): boolean {
130
+ if (a === b) return true
131
+ if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]))
132
+ if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(b)) {
133
+ const aKeys = Object.keys(a)
134
+ return aKeys.length === Object.keys(b).length && aKeys.every((key) => jsonEqual((a as any)[key], (b as any)[key]))
135
+ }
136
+ return false
137
+ }
138
+
139
+ // argoApplicationSyncOptions returns spec.syncPolicy.syncOptions: the options
140
+ // Argo CD applies to every sync of the Application unless a sync names its own.
141
+ export function argoApplicationSyncOptions(app: any): string[] {
142
+ const options = app?.spec?.syncPolicy?.syncOptions
143
+ return Array.isArray(options) ? options.filter((option): option is string => typeof option === 'string') : []
144
+ }
145
+
67
146
  export function isArgoOperationInProgress(app: any): boolean {
68
147
  const phase = app?.status?.operationState?.phase
69
148
  return app?.operation != null || phase === 'Running' || phase === 'Terminating'
70
149
  }
71
150
 
72
151
  // Radar suspends an Argo Application by clearing spec.syncPolicy.automated and
73
- // recording the prior prune/selfHeal flags in annotations so Resume can restore
74
- // them. The *presence* of any of these annotations marks the app as suspended —
152
+ // recording the prior block in annotations (the whole block, plus the
153
+ // prune/selfHeal flags older builds read) so Resume can restore it. The *presence* of any of these annotations marks the app as suspended —
75
154
  // independent of health.status, which stays whatever the app's resources report
76
155
  // (often Missing/OutOfSync, never literally "Suspended"). Both the current
77
156
  // radarhq.io keys and the legacy skyhook.io keys (still on apps suspended by
@@ -0,0 +1,32 @@
1
+ import { expect, it } from 'vitest'
2
+ import { getCRDStatus, getCRDColumnValue } from './resource-utils-crd'
3
+ import { getCellFilterValue } from './resource-utils'
4
+ import { getGenericResourceStatus } from './generic-status'
5
+ import { getResourceStatus } from '../shared/ResourceRendererDispatch'
6
+
7
+ it.each([
8
+ [{ type: 'Established', status: 'True' }, 'Established', 'healthy'],
9
+ [{ type: 'Established', status: 'False', message: 'waiting' }, 'Not established', 'unhealthy'],
10
+ [{ type: 'NamesAccepted', status: 'False', reason: 'Conflict', message: 'name taken' }, 'Conflict', 'unhealthy'],
11
+ [{ type: 'Terminating', status: 'True' }, 'Terminating', 'degraded'],
12
+ [{ type: 'Established', status: 'Unknown' }, 'Unknown', 'unknown'],
13
+ [undefined, 'Unknown', 'unknown'],
14
+ ])('shares CRD condition classification: %j', (condition, text, tone) => {
15
+ const data = { kind: 'CustomResourceDefinition', apiVersion: 'apiextensions.k8s.io/v1', status: { conditions: condition ? [condition] : [] } }
16
+ expect(getCRDStatus(data)).toMatchObject({ text, tone })
17
+ expect(getGenericResourceStatus(data)).toEqual(getCRDStatus(data))
18
+ expect(getResourceStatus('customresourcedefinitions', data)?.text).toBe(text)
19
+ })
20
+ it('prioritizes termination and rejected names over establishment', () => {
21
+ const status = { conditions: [{ type: 'Established', status: 'True' }, { type: 'NamesAccepted', status: 'False' }] }
22
+ expect(getCRDStatus({ status }).tone).toBe('unhealthy')
23
+ expect(getCRDStatus({ metadata: { deletionTimestamp: 'now' }, status }).text).toBe('Terminating')
24
+ })
25
+
26
+ it('filters the CRD list by the declared kind and served versions, not the CRD object itself', () => {
27
+ const crd = { apiVersion: 'apiextensions.k8s.io/v1', kind: 'CustomResourceDefinition', spec: { scope: 'Cluster', names: { kind: 'Widget' }, versions: [{ name: 'v1', served: true }, { name: 'v2', served: false }] } }
28
+ expect(getCellFilterValue(crd, 'kind', 'customresourcedefinitions')).toBe('Widget')
29
+ expect(getCellFilterValue(crd, 'versions', 'customresourcedefinitions')).toBe('v1')
30
+ expect(getCellFilterValue(crd, 'scope', 'customresourcedefinitions')).toBe('Cluster')
31
+ expect(getCRDColumnValue({ ...crd, apiVersion: 'other.example.io/v1' }, 'kind')).toBeUndefined()
32
+ })
@@ -0,0 +1,26 @@
1
+ import type { GenericStatus } from './generic-status'
2
+ import { isApiGroup } from './resource-utils-cnpg'
3
+
4
+ export function getCRDStatus(data: any): GenericStatus {
5
+ const conditions: any[] = data.status?.conditions || []
6
+ const terminating = conditions.find(c => c.type === 'Terminating' && c.status === 'True')
7
+ if (data.metadata?.deletionTimestamp || terminating) return { text: 'Terminating', tone: 'degraded', reason: terminating?.message }
8
+ for (const [type, fallback] of [['NamesAccepted', 'Names not accepted'], ['Established', 'Not established']]) {
9
+ const condition = conditions.find(c => c.type === type && c.status === 'False')
10
+ if (condition) return { text: condition.reason || fallback, tone: 'unhealthy', reason: condition.message }
11
+ }
12
+ return conditions.some(c => c.type === 'Established' && c.status === 'True')
13
+ ? { text: 'Established', tone: 'healthy' }
14
+ : { text: 'Unknown', tone: 'unknown' }
15
+ }
16
+
17
+ export function getCRDColumnValue(data: any, column: string): string | undefined {
18
+ if (data?.kind !== 'CustomResourceDefinition' || !isApiGroup(data.apiVersion, 'apiextensions.k8s.io')) return undefined
19
+ switch (column) {
20
+ case 'kind': return data.spec?.names?.kind ?? ''
21
+ case 'scope': return data.spec?.scope ?? ''
22
+ case 'versions': return (data.spec?.versions ?? []).filter((v: any) => v?.served).map((v: any) => v.name).join(', ')
23
+ case 'status': return getCRDStatus(data).text
24
+ default: return undefined
25
+ }
26
+ }
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
1
3
  import { describe, expect, it } from 'vitest'
2
4
  import {
3
5
  getClusterQueueCohort,
@@ -335,3 +337,13 @@ describe('JobSet status evidence boundaries', () => {
335
337
  expect(getJobSetStatus({ spec: { suspend: true }, status: { conditions } }).text).toBe('Suspended')
336
338
  })
337
339
  })
340
+
341
+ // The topology node (pkg/topology) and Go's lifecycle read the same vectors.
342
+ describe('JobSet status shared lifecycle vectors', () => {
343
+ const vectors = JSON.parse(readFileSync(fileURLToPath(new URL('../../../../../pkg/jobset/testdata/lifecycle.json', import.meta.url)), 'utf8')) as {
344
+ cases: { name: string; jobSet: unknown; label: string; level: string }[]
345
+ }
346
+ it.each(vectors.cases.map((c) => [c.name, c] as const))('%s', (_name, c) => {
347
+ expect(getJobSetStatus(c.jobSet)).toMatchObject({ text: c.label, level: c.level })
348
+ })
349
+ })