@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,106 @@
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 { CustomResourceDefinitionRenderer } from './CustomResourceDefinitionRenderer'
6
+
7
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
8
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
9
+ let root: ReturnType<typeof createRoot>
10
+ let host: HTMLDivElement
11
+ const schema = { type: 'object', properties: { spec: { type: 'object', description: 'Specification', required: ['field0'], properties: Object.fromEntries(Array.from({ length: 205 }, (_, i) => [`field${i}`, { type: 'string', description: `Description ${i}`, format: 'uri', 'x-kubernetes-validations': [{ rule: 'self.size() > 0', message: 'Must be set' }], oneOf: [{ required: ['a'] }, { required: ['b'] }] }])) } } }
12
+ const data = { apiVersion: 'apiextensions.k8s.io/v1', metadata: { uid: 'a', resourceVersion: '1' }, spec: { group: 'example.io', scope: 'Namespaced', names: { kind: 'Widget', plural: 'widgets', singular: 'widget' }, versions: [{ name: 'v1beta1', served: true, storage: true, schema: { openAPIV3Schema: { properties: { old: { type: 'string' } } } } }, { name: 'v1', served: true, schema: { openAPIV3Schema: schema } }, { name: 'v2', served: false }] } }
13
+ afterEach(() => { act(() => root?.unmount()); host?.remove() })
14
+ function mount(resource: unknown = data, onViewInstances?: () => void) {
15
+ if (!host) { host = document.createElement('div'); document.body.append(host); root = createRoot(host) }
16
+ act(() => root.render(<CustomResourceDefinitionRenderer data={resource} onViewInstances={onViewInstances} />))
17
+ }
18
+ function start(onViewInstances?: () => void) { host = document.createElement('div'); document.body.append(host); root = createRoot(host); mount(data, onViewInstances) }
19
+ function click(text: string) { const button = Array.from(host.querySelectorAll('button')).find(b => b.textContent?.includes(text)); expect(button).toBeDefined(); act(() => button!.click()) }
20
+ function filter(value: string) { const input = host.querySelector('input')!; act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })) }) }
21
+ const versionButton = () => host.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')
22
+ function openVersions() { act(() => versionButton()!.click()); return Array.from(document.querySelectorAll<HTMLButtonElement>('[role="option"]')) }
23
+ function version(value: string) { const option = openVersions().find(o => o.textContent === value || o.textContent?.match(new RegExp(`^${value}(storage|not served|schema)`))); expect(option).toBeDefined(); act(() => option!.click()) }
24
+
25
+ it('shows definition facts, highest served version and optional Instances action', () => {
26
+ const view = vi.fn(); start(view)
27
+ expect(host.textContent).toContain('Widget')
28
+ expect(host.textContent).toContain('example.io')
29
+ expect(host.textContent).toContain('Namespaced')
30
+ expect(versionButton()?.getAttribute('aria-label')).toBe('Schema version: v1')
31
+ const options = openVersions()
32
+ expect(options.map(o => o.textContent)).toEqual(['v1beta1storage', 'v1', 'v2not served · schema not received'])
33
+ act(() => options[1].click())
34
+ click('View Widget resources'); expect(view).toHaveBeenCalledOnce()
35
+ mount(data); expect(host.textContent).not.toContain('View Widget resources')
36
+ })
37
+ it('lazily expands at most 100 children, reveals details and pages siblings', () => {
38
+ start(); expect(host.textContent).not.toContain('field0')
39
+ click('spec'); expect(host.textContent).toContain('field99'); expect(host.textContent).not.toContain('field100')
40
+ click('field0'); expect(host.textContent).toContain('format: uri'); expect(host.textContent).toContain('self.size() > 0'); expect(host.textContent).toContain('Must be set'); expect(host.textContent).toContain('One of: a · b')
41
+ click('Show 100 more'); expect(host.textContent).toContain('field199'); expect(host.textContent).not.toContain('field200')
42
+ click('Show 5 more'); expect(host.textContent).toContain('field204')
43
+ })
44
+ it('caps filter results, keeps the filter across versions and updates, and resets on another CRD', () => {
45
+ start(); filter('SPEC.FIELD')
46
+ expect(host.textContent).toContain('205 matching fields — showing 200')
47
+ expect(host.textContent).toContain('spec.field0')
48
+ expect(host.textContent).not.toContain('spec.field200')
49
+ version('v1beta1'); expect(host.querySelector('input')?.value).toBe('SPEC.FIELD'); expect(host.textContent).toContain('0 matching fields')
50
+ filter(''); expect(host.textContent).toContain('old')
51
+ version('v1'); click('spec'); expect(host.textContent).toContain('field0')
52
+ version('v1beta1'); version('v1'); expect(host.textContent).not.toContain('field0')
53
+ filter('field1'); mount({ ...data, metadata: { uid: 'a', resourceVersion: '2' } }); expect(host.querySelector('input')?.value).toBe('field1')
54
+ mount({ ...data, metadata: { uid: 'b', resourceVersion: '1' } }); expect(host.querySelector('input')?.value).toBe('')
55
+ })
56
+ it('shows constraints nested inside arrays of maps on the field that holds them', () => {
57
+ start(); mount({ ...data, metadata: { uid: 'n' }, spec: { ...data.spec, versions: [{ name: 'v1', served: true, schema: { openAPIV3Schema: { properties: { tiers: { type: 'array', items: { type: 'object', additionalProperties: { type: 'string', enum: ['gold', 'silver'] } } } } } } }] } })
58
+ expect(host.textContent).toContain('tiers[]map[string]string')
59
+ click('tiers'); expect(host.textContent).toContain('items values enum: gold · silver')
60
+ })
61
+ it('falls back to the preferred version when an update removes the one being read', () => {
62
+ start(); version('v1beta1'); expect(host.textContent).toContain('old')
63
+ mount({ ...data, spec: { ...data.spec, versions: data.spec.versions.filter(v => v.name !== 'v1beta1') } })
64
+ expect(versionButton()?.getAttribute('aria-label')).toBe('Schema version: v1')
65
+ expect(host.textContent).not.toContain("didn't receive a schema")
66
+ })
67
+ it('shows the rules on parent fields for a field found by search, and an empty schema plainly', () => {
68
+ const withRules = { ...data, spec: { ...data.spec, versions: [{ name: 'v1', served: true, schema: { openAPIV3Schema: { properties: { spec: { 'x-kubernetes-validations': [{ rule: 'has(self.a) != has(self.b)', message: 'set a or b' }], properties: { a: { type: 'string' }, b: { type: 'string' } } } } } } }] } }
69
+ start(); mount(withRules); filter('spec.a'); click('spec.astring')
70
+ expect(host.textContent).toContain('Rules on parent fields')
71
+ expect(host.textContent).toContain('has(self.a) != has(self.b)')
72
+ mount({ ...data, metadata: { uid: 'c' }, spec: { ...data.spec, versions: [{ name: 'v1', served: true, schema: { openAPIV3Schema: { type: 'object' } } }] } })
73
+ expect(host.textContent).toContain('This schema declares no fields.')
74
+ })
75
+ it('reports missing schemas honestly per version and for a stripped object', () => {
76
+ start(); version('v2'); expect(host.textContent).toContain("Radar didn't receive a schema for this version")
77
+ mount({ ...data, spec: { ...data.spec, versions: [{ name: 'v1', served: false }] } })
78
+ expect(host.textContent).toContain("Radar didn't receive this definition's schema. The YAML tab shows the object as returned.")
79
+ expect(host.textContent).not.toContain('View Widget resources')
80
+ })
81
+
82
+ it('shows preserve-unknown and int-or-string notes without replacing the declared type', () => {
83
+ start()
84
+ mount({ ...data, spec: { ...data.spec, versions: [{ name: 'v1', served: true, schema: { openAPIV3Schema: { properties: { extra: { type: 'object', 'x-kubernetes-preserve-unknown-fields': true }, port: { 'x-kubernetes-int-or-string': true } } } } }] } })
85
+ expect(host.textContent).toContain('extraobject')
86
+ click('extra'); expect(host.textContent).toContain('Accepts fields not declared in this schema.')
87
+ click('port'); expect(host.textContent).toContain('Accepts an integer or a string.')
88
+ })
89
+
90
+ it('suppresses boilerplate root summaries and keeps other summaries visible on expansion', () => {
91
+ start()
92
+ const short = 'Brief description'
93
+ const long = 'Long description. '.repeat(10)
94
+ mount({ ...data, spec: { ...data.spec, versions: [{ name: 'v1', served: true, schema: { openAPIV3Schema: { properties: {
95
+ apiVersion: { type: 'string', description: 'Version boilerplate' }, kind: { type: 'string', description: 'Kind boilerplate' }, metadata: { type: 'object', description: 'Metadata boilerplate' },
96
+ brief: { type: 'string', description: short }, verbose: { type: 'string', description: long },
97
+ } } } }] } })
98
+ expect(host.textContent).not.toContain('boilerplate')
99
+ click('brief')
100
+ expect(Array.from(host.querySelectorAll('button')).find(b => b.textContent?.includes('brief'))?.textContent).toContain(short)
101
+ expect(host.textContent?.split(short)).toHaveLength(2)
102
+ click('verbose')
103
+ expect(Array.from(host.querySelectorAll('p')).some(p => p.textContent === long)).toBe(true)
104
+ click('apiVersion')
105
+ expect(Array.from(host.querySelectorAll('button')).find(b => b.textContent?.includes('apiVersion'))?.getAttribute('aria-expanded')).toBe('true')
106
+ })
@@ -0,0 +1,152 @@
1
+ import { useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import { Braces, FileCode, Tags } from 'lucide-react'
3
+ import type { ResourceRef } from '../../../types'
4
+ import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
5
+ import { SelectMenu } from '../../ui/SelectMenu'
6
+ import { AlertBanner, ConditionsSection, Property, PropertyList, ResourceLink, Section } from '../../ui/drawer-components'
7
+ import { getCRDStatus } from '../resource-utils-crd'
8
+ import { CRDVersionLabel } from './crd-cells'
9
+ import { ParentRulesList, RequiredMarker, SchemaFacts, SchemaFieldFilter, SchemaPathText } from './crd-schema-parts'
10
+ import {
11
+ filterCRDSchema, indexCRDSchema, preferredCRDVersion,
12
+ schemaChildren, schemaLeafName, schemaTypeLabel,
13
+ type CRDSchema, type CRDVersion, type SchemaNode,
14
+ } from '../../../utils/crd-schema'
15
+
16
+ export interface CustomResourceDefinitionRendererProps {
17
+ data: any
18
+ onNavigate?: (ref: ResourceRef) => void
19
+ onViewInstances?: () => void
20
+ }
21
+
22
+ function versionNotes(version: CRDVersion): string | undefined {
23
+ const notes = [
24
+ version.storage && 'storage',
25
+ version.deprecated && 'deprecated',
26
+ !version.served && 'not served',
27
+ !version.schema?.openAPIV3Schema && 'schema not received',
28
+ ].filter(Boolean)
29
+ return notes.length ? notes.join(' · ') : undefined
30
+ }
31
+
32
+ function SchemaRows({ nodes, root, query }: { nodes: SchemaNode[]; root?: CRDSchema; query?: string }) {
33
+ const fullPath = !!root
34
+ const [visible, setVisible] = useState(100)
35
+ return <div className="space-y-0.5">
36
+ {nodes.slice(0, fullPath ? 200 : visible).map(node => <SchemaRow key={JSON.stringify(node.path)} node={node} root={root} query={query} />)}
37
+ {!fullPath && nodes.length > visible && <button type="button" className="text-sm text-accent" onClick={() => setVisible(n => n + 100)}>Show {Math.min(100, nodes.length - visible)} more</button>}
38
+ </div>
39
+ }
40
+
41
+ function SchemaRow({ node, root, query }: { node: SchemaNode; root?: CRDSchema; query?: string }) {
42
+ const fullPath = !!root
43
+ const [open, setOpen] = useState(false)
44
+ const disclosure = useDisclosure(open)
45
+ const schema = node.schema
46
+ return <div className="border-l border-theme-border pl-2">
47
+ <button type="button" {...disclosure.buttonProps} onClick={() => setOpen(v => !v)} className="flex w-full items-start gap-1.5 rounded py-1 text-left text-sm hover:bg-theme-hover">
48
+ <CollapseChevron open={open} className="mt-1 h-3 w-3 shrink-0" />
49
+ <span className="min-w-0 flex-1">
50
+ {fullPath && <SchemaPathText path={node.path} query={query} part="parent" />}
51
+ <span className="flex flex-wrap items-baseline gap-x-2">
52
+ <span className="font-mono break-all text-theme-text-primary">{fullPath ? <SchemaPathText path={node.path} query={query} part="leaf" /> : schemaLeafName(node.path)}</span>
53
+ <span className="text-xs text-theme-text-tertiary">{schemaTypeLabel(schema)}</span>
54
+ {node.required && <RequiredMarker />}
55
+ </span>
56
+ {schema.description && !(node.path.length === 1 && ['apiVersion', 'kind', 'metadata'].includes(schemaLeafName(node.path))) && <span className="block truncate text-theme-text-tertiary">{schema.description}</span>}
57
+ </span>
58
+ </button>
59
+ <Collapse open={open} id={disclosure.panelId} mountLazily>
60
+ <div className="space-y-2 pb-2 pl-5 text-sm text-theme-text-secondary">
61
+ <SchemaFacts schema={schema} showDescription={(schema.description?.length ?? 0) > 100} />
62
+ {root ? <ParentRulesList node={node} root={root} /> : <SchemaChildren node={node} />}
63
+ </div>
64
+ </Collapse>
65
+ </div>
66
+ }
67
+
68
+ function SchemaChildren({ node }: { node: SchemaNode }) {
69
+ const children = useMemo(() => schemaChildren(node.schema, node.path, node), [node])
70
+ return children.length > 0 ? <SchemaRows nodes={children} /> : null
71
+ }
72
+
73
+ function SchemaExplorer({ version, versionPicker, filter, onFilterChange }: {
74
+ version?: CRDVersion
75
+ versionPicker?: ReactNode
76
+ filter: string
77
+ onFilterChange: (filter: string) => void
78
+ }) {
79
+ const index = useRef<{ schema: CRDSchema; nodes: SchemaNode[] } | null>(null)
80
+ const schema = version?.schema?.openAPIV3Schema
81
+ const nodes = useMemo(() => schema ? schemaChildren(schema) : [], [schema])
82
+ let results: ReturnType<typeof filterCRDSchema> | undefined
83
+ if (schema && filter.trim()) {
84
+ if (index.current?.schema !== schema) index.current = { schema, nodes: indexCRDSchema(schema) }
85
+ results = filterCRDSchema(index.current.nodes, filter)
86
+ }
87
+ return <div className="space-y-3">
88
+ <div className="flex items-center gap-2">
89
+ {schema && <SchemaFieldFilter value={filter} onChange={onFilterChange} />}
90
+ {versionPicker && <div className="flex shrink-0 items-center gap-2 text-xs text-theme-text-secondary"><span>Version</span><div className="w-32">{versionPicker}</div></div>}
91
+ </div>
92
+ {!schema ? <p className="text-sm text-theme-text-tertiary">Radar didn't receive a schema for this version. The YAML tab shows the object as returned.</p>
93
+ : results ? <>
94
+ <p className="text-sm text-theme-text-tertiary">{results.total === 1 ? '1 matching field' : `${results.total} matching fields`}{results.total > 200 ? ' — showing 200; narrow the filter' : ''}</p>
95
+ <SchemaRows key="filter" nodes={results.nodes} root={schema} query={filter} />
96
+ </> : <>
97
+ <div className="space-y-2 text-sm text-theme-text-secondary"><SchemaFacts schema={{ ...schema, description: undefined }} /></div>
98
+ {nodes.length > 0 ? <SchemaRows key="tree" nodes={nodes} /> : <p className="text-sm text-theme-text-tertiary">This schema declares no fields.</p>}
99
+ </>}
100
+ </div>
101
+ }
102
+
103
+ export function CustomResourceDefinitionRenderer(props: CustomResourceDefinitionRendererProps) {
104
+ const identity = JSON.stringify([props.data.metadata?.uid, props.data.metadata?.name])
105
+ return <CRDDefinition key={identity} {...props} identity={identity} />
106
+ }
107
+
108
+ function CRDDefinition({ data, onNavigate, onViewInstances, identity }: CustomResourceDefinitionRendererProps & { identity: string }) {
109
+ const spec = data.spec || {}
110
+ const names = spec.names || {}
111
+ const versions: CRDVersion[] = spec.versions || []
112
+ const [picked, setSelected] = useState(() => preferredCRDVersion(versions)?.name)
113
+ const [filter, setFilter] = useState('')
114
+ // An update can remove the version being read; fall back rather than
115
+ // reporting its schema as missing.
116
+ const version = versions.find(v => v.name === picked) ?? preferredCRDVersion(versions)
117
+ const selected = version?.name
118
+ const status = getCRDStatus(data)
119
+ const service = spec.conversion?.webhook?.clientConfig?.service
120
+ const allMissing = versions.every(v => !v.schema?.openAPIV3Schema)
121
+ return <>
122
+ {(status.tone === 'unhealthy' || status.tone === 'degraded') && <AlertBanner variant={status.tone === 'unhealthy' ? 'error' : 'warning'} title={status.text} message={status.reason} />}
123
+ <Section title="Definition" icon={FileCode} defaultExpanded={true}>
124
+ <PropertyList>
125
+ <Property label="Kind" value={names.kind} />
126
+ <Property label="Group" value={spec.group} />
127
+ <Property label="Scope" value={spec.scope} />
128
+ <Property label="Versions" value={<div className="space-y-1">{versions.map(v => <div key={v.name}><CRDVersionLabel version={v} />{v.deprecationWarning && <p className="text-sm text-theme-text-tertiary">{v.deprecationWarning}</p>}</div>)}</div>} />
129
+ {onViewInstances && versions.some(v => v.served) && <Property label="Instances" value={<button type="button" onClick={onViewInstances} className="text-accent hover:underline">View {names.kind} resources →</button>} />}
130
+ </PropertyList>
131
+ </Section>
132
+ <Section title="Schema" icon={Braces} defaultExpanded={true}>
133
+ <div className="space-y-3">
134
+ {allMissing ? <p className="text-sm text-theme-text-tertiary">Radar didn't receive this definition's schema. The YAML tab shows the object as returned.</p>
135
+ : <SchemaExplorer key={`${identity}:${selected}`} version={version} filter={filter} onFilterChange={setFilter} versionPicker={versions.length > 1 && <SelectMenu ariaLabel="Schema version" value={selected || ''} onChange={setSelected}
136
+ options={versions.map(v => ({ value: v.name, label: v.name, description: versionNotes(v) }))} />} />}
137
+ </div>
138
+ </Section>
139
+ <Section title="Names & conversion" icon={Tags} defaultExpanded={false}>
140
+ <PropertyList>
141
+ <Property label="Plural" value={names.plural} />
142
+ <Property label="Singular" value={names.singular} />
143
+ <Property label="List kind" value={names.listKind} />
144
+ <Property label="Short names" value={names.shortNames?.join(', ')} />
145
+ <Property label="Categories" value={names.categories?.join(', ')} />
146
+ <Property label="Conversion strategy" value={spec.conversion?.strategy} />
147
+ {service && <Property label="Webhook service" value={<><ResourceLink kind="Service" namespace={service.namespace} name={service.name} onNavigate={onNavigate} />{service.path && <span className="font-mono ml-1">{service.path}</span>}</>} />}
148
+ </PropertyList>
149
+ </Section>
150
+ <ConditionsSection conditions={data.status?.conditions} />
151
+ </>
152
+ }
@@ -0,0 +1,35 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { JobRenderer } from './JobRenderer'
4
+
5
+ const job = { apiVersion: 'batch/v1', kind: 'Job', metadata: {}, spec: { suspend: true } }
6
+
7
+ describe('Job suspension banner', () => {
8
+ it('keeps the ordinary suspension advice without Kueue evidence', () => {
9
+ expect(renderToStaticMarkup(<JobRenderer data={job} />)).toContain('Pods will not be created until this Job is resumed.')
10
+ })
11
+
12
+ it('recognizes the queue label without a host lookup', () => {
13
+ const html = renderToStaticMarkup(<JobRenderer data={{ ...job, metadata: { labels: { 'kueue.x-k8s.io/queue-name': 'quota' } } }} />)
14
+ expect(html).toContain('Held by Kueue until admitted')
15
+ expect(html).not.toContain('until this Job is resumed')
16
+ })
17
+
18
+ it('accepts controller-owned admission evidence from the host', () => {
19
+ expect(renderToStaticMarkup(<JobRenderer data={job} kueueManaged />)).toContain('Held by Kueue until admitted')
20
+ })
21
+
22
+ it('does not treat a label on a colliding Job kind as Kueue evidence', () => {
23
+ const html = renderToStaticMarkup(<JobRenderer data={{ ...job, apiVersion: 'batch.volcano.sh/v1alpha1', metadata: { labels: { 'kueue.x-k8s.io/queue-name': 'quota' } } }} />)
24
+ expect(html).toContain('Job Suspended')
25
+ expect(html).not.toContain('Held by Kueue')
26
+ })
27
+
28
+ it('preserves failures above admission and omits a hold banner when unsuspended', () => {
29
+ const html = renderToStaticMarkup(<JobRenderer data={{ ...job, status: { conditions: [{ type: 'Failed', status: 'True', reason: 'DeadlineExceeded' }] } }} kueueManaged admissionContent={<p>admission evidence</p>} />)
30
+ expect(html).toContain('Job Issues')
31
+ expect(html.indexOf('Job Issues')).toBeLessThan(html.indexOf('admission evidence'))
32
+ expect(html).not.toContain('Held by Kueue')
33
+ expect(renderToStaticMarkup(<JobRenderer data={{ ...job, spec: { suspend: false } }} kueueManaged />)).not.toContain('Held by Kueue')
34
+ })
35
+ })
@@ -6,6 +6,7 @@ import { formatDuration } from '../resource-utils'
6
6
  interface JobRendererProps {
7
7
  data: any
8
8
  admissionContent?: ReactNode
9
+ kueueManaged?: boolean
9
10
  }
10
11
 
11
12
  // Extract problems from Job status and conditions
@@ -44,7 +45,7 @@ function getJobProblems(data: any): string[] {
44
45
  return problems
45
46
  }
46
47
 
47
- export function JobRenderer({ data, admissionContent }: JobRendererProps) {
48
+ export function JobRenderer({ data, admissionContent, kueueManaged = false }: JobRendererProps) {
48
49
  const status = data.status || {}
49
50
  const spec = data.spec || {}
50
51
  const conditions = status.conditions || []
@@ -66,6 +67,7 @@ export function JobRenderer({ data, admissionContent }: JobRendererProps) {
66
67
  // Check if job completed successfully
67
68
  const isComplete = conditions.some((c: any) => c.type === 'Complete' && c.status === 'True')
68
69
  const isSuspended = spec.suspend === true
70
+ const heldByKueue = kueueManaged || (data.apiVersion === 'batch/v1' && data.kind === 'Job' && Boolean(data.metadata?.labels?.['kueue.x-k8s.io/queue-name']))
69
71
 
70
72
  return (
71
73
  <>
@@ -76,7 +78,10 @@ export function JobRenderer({ data, admissionContent }: JobRendererProps) {
76
78
 
77
79
  {/* Suspended is an intentional state, not a fault — keep it informational. */}
78
80
  {isSuspended && !hasProblems && (
79
- <AlertBanner variant="info" title="Job Suspended" message="Pods will not be created until this Job is resumed." />
81
+ <AlertBanner variant="info"
82
+ title={heldByKueue ? 'Held by Kueue until admitted' : 'Job Suspended'}
83
+ message={heldByKueue ? 'See Kueue admission for the queue and admission evidence.' : 'Pods will not be created until this Job is resumed.'}
84
+ />
80
85
  )}
81
86
 
82
87
  {/* Success banner */}
@@ -49,8 +49,8 @@ describe('queue quota accounting', () => {
49
49
  describe('queue status and policy', () => {
50
50
  it('does not turn absent status into zero workloads or healthy state', () => {
51
51
  const html = render(base)
52
- expect(html).toContain('Controller status has not been reported')
53
- expect(html).not.toContain('No Active condition reported')
52
+ expect(html).toContain('No Active condition reported')
53
+ expect(html).toContain('Not reported')
54
54
  expect(html).toContain('Unknown')
55
55
  const counted = render({ ...base, status: { pendingWorkloads: 0, reservingWorkloads: 1, admittedWorkloads: 1 } })
56
56
  expect(counted).not.toContain('counts have not been reported')
@@ -79,8 +79,8 @@ describe('queue status and policy', () => {
79
79
  const data = { ...base, spec: { stopPolicy }, status: { conditions: [{ type: 'Active', status: 'False', reason: 'FlavorNotFound', message: 'flavor missing' }] } }
80
80
  expect(getClusterQueueStatus(data).text).toBe('FlavorNotFound')
81
81
  const html = render(data)
82
- expect(html).toContain('cancel reservations')
83
- expect(html).toContain(stopPolicy === 'Hold' ? 'may continue' : 'are to be evicted')
82
+ expect(html).toContain('reservations are cancelled')
83
+ expect(html).toContain(stopPolicy === 'Hold' ? 'admitted Workloads run to completion' : 'admitted Workloads are evicted')
84
84
  expect(html).toContain('flavor missing')
85
85
  })
86
86
 
@@ -111,7 +111,7 @@ it('omits zero borrowing and irrelevant cohort policies on a real-shaped standal
111
111
  } }
112
112
  const html = render(data)
113
113
  expect(html).not.toContain('Nominal quota is not a hard ceiling')
114
- for (const text of ['Borrowed reservation:', 'Reclaim within Cohort', 'Borrow with Preemption', 'Flavor when Borrowing']) expect(html).not.toContain(text)
114
+ for (const text of ['Borrowed reservation:', 'Reclaim within cohort', 'Borrow with preemption', 'Flavor when borrowing']) expect(html).not.toContain(text)
115
115
  const cohort = render({ ...data, spec: { ...data.spec, cohortName: 'shared' } })
116
116
  expect(cohort).toContain('Nominal quota is not a hard ceiling')
117
117
  expect(cohort).toContain('borrowing has no configured cap')