@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
@@ -26,7 +26,7 @@ describe('Kueue Workload detail', () => {
26
26
  admissionChecks: [{ name: 'capacity', state: 'Pending', message: 'Waiting for provisioned nodes' }],
27
27
  conditions: [{ type: 'QuotaReserved', status: 'True' }, { type: 'Admitted', status: 'False', reason: 'AdmissionCheck' }],
28
28
  }, { ...base, apiVersion: `kueue.x-k8s.io/${version}` })
29
- for (const text of ['QuotaReserved', 'Pods at Reservation', 'Resources at Reservation', 'CPU: 4', 'nvidia.com/gpu: a100', 'team-quota', 'Waiting for provisioned nodes']) expect(html).toContain(text)
29
+ for (const text of ['QuotaReserved', 'Pods at Reservation', 'Resources at Reservation', 'CPU: 4', 'nvidia.com/gpu', '>a100<', 'team-quota', 'Waiting for provisioned nodes']) expect(html).toContain(text)
30
30
  expect(html).not.toContain('Admitted Pods')
31
31
  expect(html).not.toContain('Admitted Demand')
32
32
  })
@@ -12,14 +12,21 @@ import {
12
12
  type ConditionTone,
13
13
  } from '../../ui/drawer-components'
14
14
  import { formatResources } from '../resource-utils'
15
+ import type { Relationships } from '../../../types'
16
+ import { AdmissionPath, FlavorReasons, type KueueWorkloadReads } from '../../kueue/AdmissionPath'
17
+ import { KUEUE_GROUP } from '../../kueue/constants'
15
18
  import { admissionCheckSeverity, getKueueWorkloadStatus, getKueueWorkloadPriority, getKueueWorkloadStatusCondition, isKueueWorkloadFailureReason, isKueueConditionStale } from '../resource-utils-kueue'
16
19
 
17
- const KUEUE_GROUP = 'kueue.x-k8s.io'
18
-
19
20
  interface KueueWorkloadRendererProps {
20
21
  data: any
21
22
  provisioning?: ReactNode
23
+ /** Kueue's reported pending position, supplied by the host (it reads the visibility API). */
24
+ pendingPosition?: ReactNode
22
25
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
26
+ /** The Workload's own REST relationships: its owner comes from the ownership answer. */
27
+ relationships?: Relationships
28
+ /** Objects on the admission path, read as the viewer. Absent: names only. */
29
+ reads?: KueueWorkloadReads
23
30
  }
24
31
 
25
32
  function resourceList(resources: any): string | null {
@@ -46,24 +53,9 @@ export function getKueueWorkloadConditionTone(condition: any): ConditionTone {
46
53
  return 'unknown'
47
54
  }
48
55
 
49
- function FlavorAssignments({ flavors, onNavigate }: { flavors: any; onNavigate?: KueueWorkloadRendererProps['onNavigate'] }) {
50
- if (!flavors || typeof flavors !== 'object' || Object.keys(flavors).length === 0) return null
51
- return (
52
- <div className="space-y-1 break-all [&_button]:text-left">
53
- {Object.entries(flavors).map(([resource, flavor]) => (
54
- <div key={resource} className="text-xs">
55
- {resource}:{' '}
56
- <ResourceLink name={String(flavor)} kind="resourceflavors" group={KUEUE_GROUP} onNavigate={onNavigate} />
57
- </div>
58
- ))}
59
- </div>
60
- )
61
- }
62
-
63
- export function KueueWorkloadRenderer({ data, onNavigate, provisioning }: KueueWorkloadRendererProps) {
56
+ export function KueueWorkloadRenderer({ data, onNavigate, provisioning, pendingPosition, relationships, reads }: KueueWorkloadRendererProps) {
64
57
  const spec = data.spec || {}
65
58
  const status = data.status || {}
66
- const namespace = data.metadata?.namespace || ''
67
59
  const workloadStatus = getKueueWorkloadStatus(data)
68
60
  const priority = getKueueWorkloadPriority(data)
69
61
  const statusCondition = getKueueWorkloadStatusCondition(data)
@@ -95,29 +87,10 @@ export function KueueWorkloadRenderer({ data, onNavigate, provisioning }: KueueW
95
87
 
96
88
  {stale && <AlertBanner variant="info" title="Reported state describes an earlier generation" message={`Workload generation ${data.metadata.generation}. Stale condition: ${statusCondition?.type} (generation ${statusCondition?.observedGeneration}). Reported state may not reflect the current specification.`} />}
97
89
 
98
- <Section title="Admission" icon={Gauge} defaultExpanded>
90
+ <Section variant="card" title="Admission" icon={Gauge} defaultExpanded>
99
91
  <PropertyList>
100
92
  <Property label="Reported State" value={<Badge colorClass={stale ? undefined : workloadStatus.color} severity={stale ? 'neutral' : undefined}>{workloadStatus.text}</Badge>} />
101
- <Property
102
- label="Local Queue"
103
- value={
104
- spec.queueName ? (
105
- <ResourceLink name={spec.queueName} kind="localqueues" namespace={namespace} group={KUEUE_GROUP} onNavigate={onNavigate} />
106
- ) : (
107
- 'Not assigned'
108
- )
109
- }
110
- />
111
- <Property
112
- label="Cluster Queue"
113
- value={
114
- status.admission?.clusterQueue ? (
115
- <ResourceLink name={status.admission.clusterQueue} kind="clusterqueues" group={KUEUE_GROUP} onNavigate={onNavigate} />
116
- ) : (
117
- 'No reservation reported'
118
- )
119
- }
120
- />
93
+ {pendingPosition && <Property label="Queue Position" value={pendingPosition} />}
121
94
  {priority !== '-' && <Property label="Priority" value={priority} />}
122
95
  {checks.length > 0 && (
123
96
  <Property label="Reported Checks" value={Array.from(checkCounts).filter(([, count]) => count > 0).map(([state, count]) => `${count} ${state}`).join(' · ')} />
@@ -126,11 +99,15 @@ export function KueueWorkloadRenderer({ data, onNavigate, provisioning }: KueueW
126
99
  </PropertyList>
127
100
  </Section>
128
101
 
102
+ <AdmissionPath data={data} reads={reads} relationships={relationships} onNavigate={onNavigate} />
103
+
104
+ <FlavorReasons data={data} onNavigate={onNavigate} />
105
+
129
106
  {checks.length > 0 && (
130
- <Section title={`Admission Checks (${checks.length})`} icon={ListChecks} defaultExpanded>
131
- <div className="max-w-2xl space-y-2">
107
+ <Section variant="card" title={`Admission Checks (${checks.length})`} icon={ListChecks} defaultExpanded>
108
+ <div className="divide-y divide-theme-border">
132
109
  {checks.map((check: any, index: number) => (
133
- <div key={check?.name || index} className="card-inner">
110
+ <div key={check?.name || index} className="py-3 first:pt-0 last:pb-0">
134
111
  <div className="mb-2 grid max-w-md grid-cols-[minmax(0,1fr)_6rem] items-start gap-2">
135
112
  <span className="min-w-0 break-words text-sm font-medium text-theme-text-primary [&_button]:text-left">
136
113
  <ResourceLink
@@ -160,14 +137,14 @@ export function KueueWorkloadRenderer({ data, onNavigate, provisioning }: KueueW
160
137
 
161
138
  {provisioning}
162
139
 
163
- <ConditionsSection conditions={status.conditions} getConditionTone={(condition) => isKueueConditionStale(condition, data.metadata?.generation) ? 'unknown' : getKueueWorkloadConditionTone(condition)} defaultExpanded />
140
+ <ConditionsSection variant="card" conditions={status.conditions} getConditionTone={(condition) => isKueueConditionStale(condition, data.metadata?.generation) ? 'unknown' : getKueueWorkloadConditionTone(condition)} defaultExpanded />
164
141
 
165
- <Section title={`Pod Sets (${podSets.length})`} icon={Cpu} defaultExpanded>
142
+ <Section variant="card" title={`Pod Sets (${podSets.length})`} icon={Cpu} defaultExpanded>
166
143
  <p className="mb-2 text-xs text-theme-text-tertiary">
167
144
  Controller-reported requests for each PodSet, not measured usage. Reservation values are recorded when quota is assigned; they do not decrease after quota reclamation or completion.
168
145
  </p>
169
146
  {podSets.length > 0 ? (
170
- <div className="space-y-2">
147
+ <div className="divide-y divide-theme-border">
171
148
  {podSets.map((podSet: any) => {
172
149
  const name = podSet?.name || 'main'
173
150
  const assignment: any = assignments.get(name)
@@ -177,7 +154,7 @@ export function KueueWorkloadRenderer({ data, onNavigate, provisioning }: KueueW
177
154
  const reserved = status.admission != null
178
155
  const reclaimableCount = reclaimable.get(name)
179
156
  return (
180
- <div key={name} className="card-inner">
157
+ <div key={name} className="py-3 first:pt-0 last:pb-0">
181
158
  <div className="mb-2 flex flex-wrap items-center gap-2">
182
159
  <span className="text-sm font-medium text-theme-text-primary">{name}</span>
183
160
  <Badge tone="structural" size="sm">
@@ -196,12 +173,6 @@ export function KueueWorkloadRenderer({ data, onNavigate, provisioning }: KueueW
196
173
  label={reserved ? 'Resources at Reservation' : 'Evaluated Requests'}
197
174
  value={reserved ? reservedResources || 'Not reported' : evaluatedDemand || 'Not reported'}
198
175
  />
199
- {assignment?.flavors && (
200
- <Property
201
- label="Resource Flavors"
202
- value={<FlavorAssignments flavors={assignment.flavors} onNavigate={onNavigate} />}
203
- />
204
- )}
205
176
  </PropertyList>
206
177
  </div>
207
178
  )
@@ -1,10 +1,12 @@
1
- import { useMemo, useState } from 'react'
1
+ import { useMemo, useState, type ReactNode } from 'react'
2
2
  import { Badge } from '../../ui/Badge'
3
3
  import { healthToSeverity } from '../../../utils/badge-colors'
4
4
  import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
5
5
  import { getCollectorPipelines, getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from '../resource-utils-opentelemetry'
6
6
 
7
- export function OpenTelemetryCollectorRenderer({ data }: { data: any }) {
7
+ // workloadEvidence is the host-provided current workload preview; shared hosts
8
+ // can omit it and retain the native reconciliation/configuration view.
9
+ export function OpenTelemetryCollectorRenderer({ data, workloadEvidence }: { data: any; workloadEvidence?: ReactNode }) {
8
10
  const state = getOpenTelemetryCollectorStatus(data)
9
11
  const preview = useMemo(() => getCollectorPipelines(data), [data])
10
12
  const [showAll, setShowAll] = useState(false)
@@ -24,6 +26,7 @@ export function OpenTelemetryCollectorRenderer({ data }: { data: any }) {
24
26
  {state.text === 'Status stale' && <p className="mt-2 text-sm text-theme-text-secondary">The reported reconciliation describes an earlier configuration generation.</p>}
25
27
  {ready?.message && <p className="mt-2 whitespace-pre-wrap break-words text-sm text-theme-text-secondary">{ready.message}</p>}
26
28
  </Section>
29
+ {workloadEvidence}
27
30
  <Section title={preview.error ? 'Configured Pipelines' : `Configured Pipelines (${preview.pipelines.length})`} defaultExpanded>
28
31
  {preview.error ? <p className="text-sm text-theme-text-secondary">{preview.error}</p> : preview.pipelines.length === 0 ? <p className="text-sm text-theme-text-tertiary">No pipelines are declared in this configuration.</p> : <>
29
32
  <div className="space-y-3">
@@ -48,6 +51,8 @@ export function OpenTelemetryInstrumentationRenderer({ data }: { data: any }) {
48
51
  const spec = data.spec ?? {}
49
52
  const state = getOpenTelemetryInstrumentationStatus(data)
50
53
  const blocked = Object.keys(data.status?.upgradeBlockedVersions ?? {})
54
+ const blockedPreview = blocked.slice(0, 10)
55
+ const languageLabel = (language: string) => language.length > 160 ? `${language.slice(0, 160)}…` : language
51
56
  const runtimeImages = ['java', 'nodejs', 'python', 'dotnet', 'go', 'apacheHttpd', 'nginx', 'php', 'ruby'].filter(language => spec[language]?.image)
52
57
  return <>
53
58
  <Section title="Auto-instrumentation Configuration" defaultExpanded>
@@ -57,13 +62,13 @@ export function OpenTelemetryInstrumentationRenderer({ data }: { data: any }) {
57
62
  <Property label="Propagators" value={spec.propagators?.join(', ')} />
58
63
  <Property label="Sampler" value={spec.sampler?.type} />
59
64
  <Property label="Sampler argument" value={spec.sampler?.argument} />
60
- {blocked.length > 0 && <Property label="Blocked image upgrades" value={blocked.join(', ')} />}
61
65
  </PropertyList>
62
66
  <p className="mt-2 text-xs text-theme-text-tertiary">These settings configure injection into annotated workloads. This resource does not report which Pods were injected or whether telemetry reaches the endpoint.</p>
63
- {blocked.length > 0 && <p className="mt-2 text-sm text-theme-text-secondary">The operator could not automatically upgrade the listed runtime images. Inspect status in YAML and the operator logs before choosing a supported image.</p>}
67
+ {blocked.length > 0 && <p className="mt-2 text-sm text-theme-text-secondary">The operator recorded these blocks during a startup upgrade check. This record can remain after image changes. Inspect the current auto-instrumentation images, status in YAML and operator logs before choosing a supported image.</p>}
64
68
  </Section>
65
- {blocked.length > 0 && <Section title="Blocked Image Upgrades" defaultExpanded>
66
- <PropertyList>{blocked.map(language => <Property key={language} label={language} value={<span className="whitespace-pre-wrap break-words">{String(data.status.upgradeBlockedVersions[language]).slice(0, 1000)}{String(data.status.upgradeBlockedVersions[language]).length > 1000 ? '… Inspect YAML for the full explanation.' : ''}</span>} />)}</PropertyList>
69
+ {blocked.length > 0 && <Section title="Recorded Image Upgrade Blocks" defaultExpanded>
70
+ <PropertyList>{blockedPreview.map(language => <Property key={language} label={languageLabel(language)} value={<span className="whitespace-pre-wrap break-words">{String(data.status.upgradeBlockedVersions[language]).slice(0, 1000)}{String(data.status.upgradeBlockedVersions[language]).length > 1000 ? '… Inspect YAML for the full explanation.' : ''}</span>} />)}</PropertyList>
71
+ {blocked.length > 10 && <p className="mt-2 text-xs text-theme-text-tertiary">{blocked.length - 10} more recorded languages in YAML.</p>}
67
72
  </Section>}
68
73
  {/* Runtime image defaults are secondary to endpoint/sampler and upgrade failures. */}
69
74
  {runtimeImages.length > 0 && <Section title="Runtime Images" defaultExpanded={false}><PropertyList>
@@ -1,7 +1,11 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
1
4
  import { renderToStaticMarkup } from 'react-dom/server'
2
5
  import { describe, expect, it } from 'vitest'
3
6
  import { RayClusterRenderer, rayClusterConditionTone } from './RayClusterRenderer'
4
7
  const root = { apiVersion: 'ray.io/v1', kind: 'RayCluster', metadata: { name: 'cluster', namespace: 'ml', uid: 'uid', generation: 2 }, spec: { workerGroupSpecs: [{ groupName: 'zero', replicas: 0, minReplicas: 0, maxReplicas: 3, numOfHosts: 2 }] }, status: { observedGeneration: 2, conditions: [{ type: 'HeadPodReady', status: 'True' }, { type: 'RayClusterProvisioned', status: 'True' }] } }
8
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
5
9
  describe('RayCluster detail evidence boundaries', () => {
6
10
  it('renders reconciled zeros, declared configuration and historical provisioning', () => {
7
11
  const html = renderToStaticMarkup(<RayClusterRenderer data={root} />)
@@ -23,3 +27,38 @@ it('distinguishes unreported counters and humanizes the API default bound', () =
23
27
  const html = renderToStaticMarkup(<RayClusterRenderer data={{ ...root, spec: { workerGroupSpecs: [{ groupName: 'workers', maxReplicas: 2147483647 }] }, status: {} }} />)
24
28
  expect(html).toContain('Not reported'); expect(html).toContain('No limit'); expect(html).not.toContain('2147483647')
25
29
  })
30
+
31
+ describe('RayCluster Pod log links', () => {
32
+ const started = (name: string) => [{ name, ready: true, restartCount: 0 }]
33
+ const evidence = { pods: [{ name: 'cluster-head-abc', ready: true, containers: ['ray-head'], containerStatuses: started('ray-head') }, { name: 'cluster-cpu-worker-def', ready: false, containers: ['wait-gcs-ready', 'ray-worker'], containerStatuses: started('ray-worker') }, { name: 'cluster-cpu-worker-new', ready: false, containers: ['wait-gcs-ready', 'ray-worker'] }, { name: 'cluster-cpu-worker-bound', ready: false, nodeName: 'node-a', containers: ['ray-worker'] }, { name: 'cluster-cpu-worker-evicted', ready: false, phase: 'Failed', nodeName: 'node-a', containers: ['ray-worker'] }, { name: 'cluster-cpu-worker-sidecar-only', ready: false, phase: 'Pending', nodeName: 'node-a', containers: ['proxy', 'ray-worker'], containerStatuses: [{ name: 'proxy', init: true, sidecar: true, ready: true, restartCount: 0 }] }], total: 6, truncated: false }
34
+ it('offers logs on each verified Pod only when the host can open them', () => {
35
+ const withLogs = renderToStaticMarkup(<RayClusterRenderer data={root} onSelectPods={() => {}} podEvidence={evidence} onOpenPodLogs={() => {}} />)
36
+ expect(withLogs).toContain('Open logs for cluster-head-abc'); expect(withLogs).toContain('Open logs for cluster-cpu-worker-def')
37
+ expect(withLogs).toContain('container output while the Pod exists'); expect(withLogs).toContain('/tmp/ray')
38
+ const without = renderToStaticMarkup(<RayClusterRenderer data={root} onSelectPods={() => {}} podEvidence={evidence} />)
39
+ expect(without).not.toContain('logs for'); expect(without).not.toContain('/tmp/ray')
40
+ })
41
+ it('says why a Pod with no reported container status has no logs instead of offering a button', () => {
42
+ const html = renderToStaticMarkup(<RayClusterRenderer data={root} onSelectPods={() => {}} podEvidence={evidence} onOpenPodLogs={() => {}} />)
43
+ expect(html).toContain('Logs for cluster-cpu-worker-new: </span>Not scheduled'); expect(html).toContain('Logs for cluster-cpu-worker-bound: </span>No status yet'); expect(html).toContain('Logs for cluster-cpu-worker-evicted: </span>Ended before starting'); expect(html).toContain('Logs for cluster-cpu-worker-sidecar-only: </span>Waiting to start')
44
+ expect(html).not.toContain('Open logs for cluster-cpu-worker-new'); expect(html).not.toContain('Open logs for cluster-cpu-worker-bound')
45
+ })
46
+ it('opens the clicked row\'s Pod', () => {
47
+ const opened: string[] = []
48
+ const host = document.createElement('div')
49
+ const view = createRoot(host)
50
+ act(() => view.render(<RayClusterRenderer data={root} onSelectPods={() => {}} podEvidence={evidence} onOpenPodLogs={pod => opened.push(pod.name)} />))
51
+ act(() => host.querySelector<HTMLButtonElement>('[aria-label="Open logs for cluster-cpu-worker-def"]')!.click())
52
+ expect(opened).toEqual(['cluster-cpu-worker-def'])
53
+ act(() => view.unmount())
54
+ })
55
+ it('offers no log links when the Pod list could not be read', () => {
56
+ const html = renderToStaticMarkup(<RayClusterRenderer data={root} onSelectPods={() => {}} podsError="Forbidden" podEvidence={evidence} onOpenPodLogs={() => {}} />)
57
+ expect(html).not.toContain('Open logs for')
58
+ })
59
+ it('asks for a newer Radar instead of showing an unsupported-route error', () => {
60
+ const html = renderToStaticMarkup(<RayClusterRenderer data={root} onSelectPods={() => {}} podsUpgrade={{ feature: 'RayCluster runtime Pods', minimumVersion: 'v1.15.0', currentVersion: 'v1.14.0' }} podsError="only deployments are supported" />)
61
+ expect(html).toContain('RayCluster runtime Pods'); expect(html).toContain('v1.15')
62
+ expect(html).not.toContain('only deployments are supported')
63
+ })
64
+ })
@@ -1,12 +1,14 @@
1
1
  import { useRef, useState } from 'react'
2
2
  import { Activity, Boxes, Server } from 'lucide-react'
3
3
  import type { ResourceRef, WorkloadPodInfo } from '../../../types'
4
+ import type { RadarUpgradeRequirement } from '../../../types/fetch-error'
5
+ import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
4
6
  import { apiVersionToGroup } from '../../../utils/navigation'
5
7
  import { Badge } from '../../ui/Badge'
6
8
  import { SelectMenu } from '../../ui/SelectMenu'
7
9
  import { AlertBanner, ConditionsSection, type ConditionTone, Property, PropertyList, ResourceLink, Section } from '../../ui/drawer-components'
8
10
  import { PodRow, PodListFrame, PodListToggle, workloadPodDetail } from '../../workload/PodList'
9
- import { isRayObservationStale, rayClusterWorkerCounts } from '../resource-utils-ray'
11
+ import { isRayObservationStale, podLogsUnavailableReason, rayClusterWorkerCounts } from '../resource-utils-ray'
10
12
 
11
13
  export interface RayClusterPodSelection { nodeType?: 'head' | 'worker'; workerGroup?: string }
12
14
  export interface RayClusterRendererProps {
@@ -17,6 +19,21 @@ export interface RayClusterRendererProps {
17
19
  podsError?: string
18
20
  selection?: RayClusterPodSelection
19
21
  onSelectPods?: (selection: RayClusterPodSelection) => void
22
+ /**
23
+ * Opens one Pod's logs. The host decides whether the viewer may read Pod
24
+ * logs; without it the rows have no Logs buttons and no logs note.
25
+ */
26
+ onOpenPodLogs?: (pod: WorkloadPodInfo) => void
27
+ /** Said in place of the logs note when the host offers no Logs buttons. */
28
+ podLogsNote?: string
29
+ /** Shown above a Pod list that is kept although its last refresh failed. */
30
+ podsNote?: string
31
+ /**
32
+ * Set when the connected Radar can't list this RayCluster's Pods: Runtime
33
+ * Pods (shown only with `onSelectPods`) then shows this upgrade note instead
34
+ * of the error, loading or Pod list. Absent says nothing about support.
35
+ */
36
+ podsUpgrade?: RadarUpgradeRequirement | null
20
37
  }
21
38
 
22
39
  export function rayClusterConditionTone(condition: any): ConditionTone {
@@ -27,7 +44,7 @@ export function rayClusterConditionTone(condition: any): ConditionTone {
27
44
  return 'unknown'
28
45
  }
29
46
 
30
- export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading, podsError, selection = {}, onSelectPods }: RayClusterRendererProps) {
47
+ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading, podsError, selection = {}, onSelectPods, onOpenPodLogs, podLogsNote, podsNote, podsUpgrade }: RayClusterRendererProps) {
31
48
  const [expanded, setExpanded] = useState(false)
32
49
  const podsRef = useRef<HTMLDivElement>(null)
33
50
  const status = data.status ?? {}
@@ -40,7 +57,7 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
40
57
  const owners = (data.metadata?.ownerReferences ?? []).filter((owner: any) => owner.controller === true && apiVersionToGroup(owner.apiVersion) === 'ray.io' && ['RayService', 'RayJob'].includes(owner.kind))
41
58
  const scope = selection.workerGroup != null ? `Worker group: ${selection.workerGroup}` : selection.nodeType === 'head' ? 'Head' : 'All runtime Pods'
42
59
  const select = (value: RayClusterPodSelection) => { setExpanded(false); onSelectPods?.(value) }
43
- const row = (pod: WorkloadPodInfo) => <PodRow key={pod.name} name={pod.name} namespace={data.metadata.namespace} ready={pod.ready} healthLevel={pod.healthLevel} detail={workloadPodDetail(pod)} onNavigate={onNavigate} />
60
+ const row = (pod: WorkloadPodInfo) => <PodRow key={pod.name} name={pod.name} namespace={data.metadata.namespace} ready={pod.ready} healthLevel={pod.healthLevel} detail={workloadPodDetail(pod)} onNavigate={onNavigate} onOpenLogs={onOpenPodLogs && (() => onOpenPodLogs(pod))} logsUnavailable={podLogsUnavailableReason(pod)} />
44
61
  return <div className="space-y-4">
45
62
  {stale && <AlertBanner variant="info" title="Status describes an earlier generation" message={`Specification generation ${data.metadata.generation}; controller observed ${status.observedGeneration}. Counts and conditions below retain that observation.`} />}
46
63
  <Section variant="card" title="Runtime Health" icon={Activity} defaultExpanded>
@@ -63,8 +80,9 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
63
80
  value={selection.workerGroup != null ? `group:${selection.workerGroup}` : selection.nodeType === 'head' ? 'head' : 'all'}
64
81
  options={[{ value: 'all', label: 'All runtime Pods' }, { value: 'head', label: 'Head' }, ...groups.map((g: any) => ({ value: `group:${g.groupName}`, label: `Worker group: ${g.groupName}` }))]}
65
82
  onChange={value => select(value === 'all' ? {} : value === 'head' ? { nodeType: 'head' } : { nodeType: 'worker', workerGroup: value.slice(6) })} />
66
- <p className="mb-2 text-xs text-theme-text-tertiary">Directly controlled by this RayCluster incarnation. Cleanup Job descendants are excluded. Open a Pod for logs.</p>
67
- {podsError ? <AlertBanner variant="warning" title="Pod evidence unavailable" message={podsError} /> : podsLoading ? <p className="text-sm text-theme-text-secondary">Reading runtime Pods…</p> : podEvidence ? <>
83
+ <p className="mb-2 text-xs text-theme-text-tertiary">Directly controlled by this RayCluster incarnation. Cleanup Job descendants are excluded.{onOpenPodLogs ? ' The Logs button shows a Pod\'s container output while the Pod exists. Ray session logs under /tmp/ray aren\'t read.' : podLogsNote ? ` ${podLogsNote}` : ''}</p>
84
+ {podsUpgrade ? <RadarUpgradeNote requirement={podsUpgrade} /> : podsError ? <AlertBanner variant="warning" title="Pod evidence unavailable" message={podsError} /> : podsLoading ? <p className="text-sm text-theme-text-secondary">Reading runtime Pods…</p> : podEvidence ? <>
85
+ {podsNote && <p className="mb-2 text-xs text-theme-text-tertiary">{podsNote}</p>}
68
86
  <p className="mb-2 text-sm text-theme-text-secondary">{scope} · {podEvidence.total} {podEvidence.total === 1 ? 'Pod' : 'Pods'}{podEvidence.truncated ? ` (showing ${podEvidence.pods.length}, failures first)` : ''}</p>
69
87
  {podEvidence.total === 0 && <p className="text-sm text-theme-text-secondary">No directly owned Pods in this scope.</p>}
70
88
  <PodListFrame expanded={expanded} hasOverflow={podEvidence.pods.length > 20} overflow={podEvidence.pods.slice(20).map(row)} toggle={<PodListToggle expanded={expanded} hiddenCount={podEvidence.pods.length - 20} label="Pods" onToggle={() => setExpanded(!expanded)} />}>{podEvidence.pods.slice(0,20).map(row)}</PodListFrame>
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { ServiceAccountRenderer } from './ServiceAccountRenderer'
6
+
7
+ it('navigates both declared Secret roles using the host resource-kind contract', async () => {
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
9
+ const container = document.createElement('div')
10
+ const root = createRoot(container)
11
+ const onNavigate = vi.fn()
12
+ try {
13
+ await act(async () => root.render(
14
+ <ServiceAccountRenderer
15
+ data={{ metadata: { name: 'runner', namespace: 'team' }, secrets: [{ name: 'token-association' }], imagePullSecrets: [{ name: 'registry' }] }}
16
+ onNavigate={onNavigate}
17
+ />,
18
+ ))
19
+ for (const name of ['token-association', 'registry']) {
20
+ const button = [...container.querySelectorAll('button')].find((button) => button.textContent === name)
21
+ expect(button).toBeDefined()
22
+ await act(async () => button!.click())
23
+ expect(onNavigate).toHaveBeenLastCalledWith({ kind: 'secrets', namespace: 'team', name, group: undefined })
24
+ }
25
+ } finally {
26
+ await act(async () => root.unmount())
27
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
28
+ }
29
+ })
30
+
31
+ it('leaves secrets[] references to other namespaces, kinds or API groups as text', async () => {
32
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
33
+ const container = document.createElement('div')
34
+ const root = createRoot(container)
35
+ try {
36
+ await act(async () => root.render(
37
+ <ServiceAccountRenderer
38
+ data={{
39
+ metadata: { name: 'runner', namespace: 'team' },
40
+ secrets: [
41
+ { name: 'foreign', namespace: 'other' },
42
+ { name: 'not-a-secret', kind: 'ConfigMap' },
43
+ { name: 'custom-group', apiVersion: 'example.io/v1' },
44
+ { name: 'local', namespace: 'team', kind: 'Secret', apiVersion: 'v1' },
45
+ ],
46
+ }}
47
+ onNavigate={vi.fn()}
48
+ />,
49
+ ))
50
+ const linked = [...container.querySelectorAll('button')].map((button) => button.textContent)
51
+ expect(linked).toContain('local')
52
+ for (const name of ['foreign', 'not-a-secret', 'custom-group']) {
53
+ expect(linked).not.toContain(name)
54
+ expect(container.textContent).toContain(name)
55
+ }
56
+ } finally {
57
+ await act(async () => root.unmount())
58
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
59
+ }
60
+ })
61
+
62
+ it('skips unnamed Secret entries instead of rendering blank links', async () => {
63
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
64
+ const container = document.createElement('div')
65
+ const root = createRoot(container)
66
+ try {
67
+ await act(async () => root.render(
68
+ <ServiceAccountRenderer
69
+ data={{ metadata: { name: 'runner', namespace: 'team' }, secrets: [{}, { name: 'token' }], imagePullSecrets: [{}, { name: 'registry' }] }}
70
+ onNavigate={vi.fn()}
71
+ />,
72
+ ))
73
+ const links = [...container.querySelectorAll('button')].map((button) => button.textContent)
74
+ expect(links).toContain('token')
75
+ expect(links).toContain('registry')
76
+ expect(links).not.toContain('')
77
+ expect(container.textContent).toContain('Secrets (1)')
78
+ expect(container.textContent).toContain('Image Pull Secrets (1)')
79
+ } finally {
80
+ await act(async () => root.unmount())
81
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
82
+ }
83
+ })
@@ -62,8 +62,9 @@ export function ServiceAccountRenderer({
62
62
  }: ServiceAccountRendererProps) {
63
63
  const metadata = data.metadata || {}
64
64
  const annotations = metadata.annotations || {}
65
- const secrets = data.secrets || []
66
- const imagePullSecrets = data.imagePullSecrets || []
65
+ // The API server accepts unnamed entries; they name no Secret to show or link.
66
+ const secrets = (data.secrets || []).filter((secret: any) => secret?.name)
67
+ const imagePullSecrets = (data.imagePullSecrets || []).filter((secret: any) => secret?.name)
67
68
 
68
69
  // automountServiceAccountToken defaults to true if not set
69
70
  const automountToken = data.automountServiceAccountToken !== false
@@ -150,7 +151,7 @@ export function ServiceAccountRenderer({
150
151
  <Section title={`Secrets (${secrets.length})`} icon={Key}>
151
152
  <PropertyList>
152
153
  {secrets.map((secret: any) => (
153
- <Property key={secret.name} label="Secret" value={secret.name} />
154
+ <Property key={secret.name} label="Secret" value={namesLocalSecret(secret, metadata.namespace) ? <ResourceLink name={secret.name} kind="secrets" namespace={metadata.namespace} onNavigate={onNavigate} /> : secret.name} />
154
155
  ))}
155
156
  </PropertyList>
156
157
  </Section>
@@ -159,14 +160,9 @@ export function ServiceAccountRenderer({
159
160
  {/* Image Pull Secrets */}
160
161
  {imagePullSecrets.length > 0 && (
161
162
  <Section title={`Image Pull Secrets (${imagePullSecrets.length})`}>
162
- <div className="flex flex-wrap gap-1">
163
+ <div className="flex flex-wrap gap-x-3 gap-y-1 text-sm">
163
164
  {imagePullSecrets.map((secret: any) => (
164
- <span
165
- key={secret.name}
166
- className="badge bg-theme-elevated text-theme-text-secondary"
167
- >
168
- {secret.name}
169
- </span>
165
+ <ResourceLink key={secret.name} name={secret.name} kind="secrets" namespace={metadata.namespace} onNavigate={onNavigate} />
170
166
  ))}
171
167
  </div>
172
168
  </Section>
@@ -175,6 +171,15 @@ export function ServiceAccountRenderer({
175
171
  )
176
172
  }
177
173
 
174
+ // secrets[] entries are ObjectReferences, which the API server stores without
175
+ // checking that they name a Secret in this namespace. Link only those that do.
176
+ function namesLocalSecret(ref: { name?: string; namespace?: string; kind?: string; apiVersion?: string }, namespace: string): boolean {
177
+ return !!ref.name
178
+ && (!ref.namespace || ref.namespace === namespace)
179
+ && (!ref.kind || ref.kind === 'Secret')
180
+ && (!ref.apiVersion || !ref.apiVersion.includes('/'))
181
+ }
182
+
178
183
  // ---------------------------------------------------------------------------
179
184
  // RBAC sections — pulled out so the main renderer body stays readable.
180
185
  // ---------------------------------------------------------------------------
@@ -0,0 +1,61 @@
1
+ import { useState } from 'react'
2
+ import type { ResourceRef } from '../../../types'
3
+ import { Badge } from '../../ui/Badge'
4
+ import { healthToSeverity } from '../../../utils/badge-colors'
5
+ import { Section, Property, PropertyList, ResourceLink, ConditionsSection, AlertBanner, useOperationalIssuesShown } from '../../ui/drawer-components'
6
+ import { getStrimziConnectorStatus } from '../resource-utils-strimzi'
7
+
8
+ export function StrimziConnectorRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
9
+ const [showAll, setShowAll] = useState(false)
10
+ const state = getStrimziConnectorStatus(data)
11
+ const operationalIssuesShown = useOperationalIssuesShown()
12
+ const spec = data.spec ?? {}
13
+ const status = data.status ?? {}
14
+ const runtime = status.connectorStatus ?? {}
15
+ const conditions = Array.isArray(status.conditions) ? status.conditions
16
+ .filter((condition: any) => typeof condition?.type === 'string' && typeof condition?.status === 'string')
17
+ .map((condition: any) => ({ type: condition.type, status: condition.status, message: typeof condition.message === 'string' && condition.message.trim() ? 'Strimzi condition message withheld; inspect status.conditions in YAML.' : undefined, reason: typeof condition.reason === 'string' ? condition.reason : undefined, lastTransitionTime: typeof condition.lastTransitionTime === 'string' ? condition.lastTransitionTime : undefined })) : []
18
+ const tasks: any[] = Array.isArray(runtime.tasks) ? [...runtime.tasks].sort((a, b) => (a?.state === 'FAILED' ? 0 : a?.state === 'RUNNING' ? 2 : 1) - (b?.state === 'FAILED' ? 0 : b?.state === 'RUNNING' ? 2 : 1)) : []
19
+ const cluster = data.metadata?.labels?.['strimzi.io/cluster']
20
+ const historical = !state.snapshotCurrent
21
+ return <>
22
+ <Section title="Connector" defaultExpanded>
23
+ <PropertyList>
24
+ <Property label="Operator snapshot" value={<Badge severity={healthToSeverity(state.level)}>{state.text}</Badge>} />
25
+ <Property label="KafkaConnect" value={cluster ? <ResourceLink kind="KafkaConnect" group="kafka.strimzi.io" name={cluster} namespace={data.metadata?.namespace} onNavigate={onNavigate} /> : 'Not specified'} />
26
+ <Property label="Connector class" value={<span className="break-all">{spec.class ?? 'Not specified'}</span>} />
27
+ <Property label="Desired state" value={spec.state ?? (data.apiVersion === 'kafka.strimzi.io/v1beta2' && spec.pause === true ? 'paused (spec.pause)' : 'running (default)')} />
28
+ <Property label="Maximum tasks" value={spec.tasksMax ?? 'Not specified'} />
29
+ </PropertyList>
30
+ {!operationalIssuesShown && state.level === 'degraded' && <AlertBanner variant="warning" title={state.text} message="Strimzi reports a failure in this operator snapshot. Inspect task states, condition details in YAML, and worker logs." />}
31
+ </Section>
32
+ <Section title="Reported Runtime" defaultExpanded>
33
+ <p className="mb-3 text-xs text-theme-text-secondary">{historical ? 'Historical or unassessed status. These observations do not establish current runtime health.' : 'Strimzi operator snapshot, not a live runtime check. Runtime state may have changed since reconciliation.'}</p>
34
+ <PropertyList>
35
+ <Property label="Connector state" value={typeof runtime.connector?.state === 'string' ? runtime.connector.state : 'Not reported'} />
36
+ <Property label="Worker" value={<span className="break-all">{typeof runtime.connector?.worker_id === 'string' ? runtime.connector.worker_id : 'Not reported'}</span>} />
37
+ <Property label="Observed / desired generation" value={`${status.observedGeneration ?? 'Unknown'} / ${data.metadata?.generation ?? 'Unknown'}`} />
38
+ </PropertyList>
39
+ <h4 className="mt-4 mb-2 text-sm font-medium">Reported tasks ({tasks.length})</h4>
40
+ {tasks.length === 0 ? <p className="text-sm text-theme-text-secondary">No task states reported. This does not establish that tasks are running.</p> : <>
41
+ <div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead><tr className="text-theme-text-secondary"><th className="pr-3 pb-2">Task</th><th className="pr-3 pb-2">State</th><th className="pb-2">Worker</th></tr></thead><tbody>
42
+ {(showAll ? tasks.slice(0, 100) : tasks.slice(0, 5)).map((task, index) => <tr key={index} className="border-t border-theme-border"><td className="py-2 pr-3">{Number.isInteger(task?.id) && task.id >= 0 ? task.id : 'Unknown'}</td><td className="py-2 pr-3">{typeof task?.state === 'string' ? task.state : 'Not reported'}</td><td className="py-2 break-all">{typeof task?.worker_id === 'string' ? task.worker_id : 'Not reported'}</td></tr>)}
43
+ </tbody></table></div>
44
+ {tasks.length > 5 && <button type="button" className="mt-2 text-sm text-accent-text hover:underline" onClick={() => setShowAll(!showAll)}>{showAll ? 'Show fewer tasks' : `Show ${Math.min(tasks.length, 100)} tasks`}</button>}
45
+ {tasks.length > 100 && <p className="mt-2 text-xs text-theme-text-secondary">Task preview is limited to 100 of {tasks.length}. Inspect YAML for the full reported list.</p>}
46
+ </>}
47
+ <p className="mt-3 text-xs text-theme-text-tertiary">For failure details, inspect task traces in YAML and worker logs. Open KafkaConnect and follow its related resources to the worker Pods. A task count below the configured maximum is not itself a failure.</p>
48
+ </Section>
49
+ {(spec.autoRestart || status.autoRestart) && <Section title="Automatic Restart" defaultExpanded>
50
+ <PropertyList>
51
+ <Property label="Enabled" value={spec.autoRestart?.enabled === undefined ? spec.autoRestart ? 'Yes (default)' : 'Not specified' : spec.autoRestart.enabled ? 'Yes' : 'No'} />
52
+ {spec.autoRestart?.enabled !== false && <Property label="Maximum restarts" value={spec.autoRestart?.maxRestarts ?? (spec.autoRestart ? 'Unlimited (default)' : 'Not specified')} />}
53
+ <Property label="Reported restarts" value={status.autoRestart?.count ?? 'Not reported'} />
54
+ <Property label="Last reported restart" value={status.autoRestart?.lastRestartTimestamp ?? 'Not reported'} />
55
+ </PropertyList>
56
+ </Section>}
57
+ {/* Topic inventory is secondary to identifying the failing task. */}
58
+ {Array.isArray(status.topics) && status.topics.length > 0 && <Section title={`Reported Topics (${status.topics.length})`} defaultExpanded={false}><p className="break-all text-sm">{status.topics.slice(0, 20).join(', ')}{status.topics.length > 20 && `; ${status.topics.length - 20} more in YAML`}</p></Section>}
59
+ <ConditionsSection conditions={conditions} getConditionTone={condition => historical ? 'unknown' : condition.type === 'NotReady' && condition.status === 'True' ? 'fail' : condition.type === 'Warning' && condition.status === 'True' ? 'warning' : 'neutral'} />
60
+ </>
61
+ }
@@ -0,0 +1,32 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { WebhookConfigRenderer } from './WebhookConfigRenderer'
6
+
7
+ describe('WebhookConfigRenderer backend navigation', () => {
8
+ for (const isMutating of [false, true]) {
9
+ it(`navigates the ${isMutating ? 'mutating' : 'validating'} backend in its declared namespace`, async () => {
10
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
11
+ const container = document.createElement('div')
12
+ const root = createRoot(container)
13
+ const onNavigate = vi.fn()
14
+ try {
15
+ await act(async () => root.render(<WebhookConfigRenderer isMutating={isMutating} onNavigate={onNavigate} data={{ webhooks: [{ name: 'hook.example.com', failurePolicy: 'Ignore', clientConfig: { service: { namespace: 'backend', name: 'admission', port: 9443, path: '/validate' } } }] }} />))
16
+ const button = [...container.querySelectorAll('button')].find(button => button.textContent === 'backend/admission')
17
+ expect(button).toBeDefined()
18
+ await act(async () => button!.click())
19
+ expect(onNavigate).toHaveBeenCalledWith({ kind: 'services', namespace: 'backend', name: 'admission' })
20
+ expect(container.textContent).toContain(':9443/validate')
21
+ await act(async () => root.render(<WebhookConfigRenderer onNavigate={onNavigate} data={{ webhooks: [{ name: 'external.example.com', clientConfig: { url: 'https://external.example.com/validate' } }] }} />))
22
+ expect(container.textContent).toContain('URL: https://external.example.com/validate')
23
+ const labels = [...container.querySelectorAll('button')].map(button => button.textContent)
24
+ expect(labels).not.toContain('external.example.com')
25
+ expect(labels).not.toContain('https://external.example.com/validate')
26
+ } finally {
27
+ await act(async () => root.unmount())
28
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
29
+ }
30
+ })
31
+ }
32
+ })
@@ -1,10 +1,12 @@
1
+ import type { ResourceRef } from '../../../types'
1
2
  import { Shield } from 'lucide-react'
2
3
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, AlertBanner, LabelSelectorDisplay } from '../../ui/drawer-components'
4
+ import { Section, PropertyList, Property, AlertBanner, LabelSelectorDisplay, ResourceLink } from '../../ui/drawer-components'
4
5
 
5
6
  interface WebhookConfigRendererProps {
6
7
  data: any
7
8
  isMutating?: boolean
9
+ onNavigate?: (ref: ResourceRef) => void
8
10
  }
9
11
 
10
12
  function getOperationColor(op: string): string {
@@ -21,7 +23,7 @@ function getSideEffectsColor(se: string): string {
21
23
  return 'status-amber'
22
24
  }
23
25
 
24
- export function WebhookConfigRenderer({ data, isMutating }: WebhookConfigRendererProps) {
26
+ export function WebhookConfigRenderer({ data, isMutating, onNavigate }: WebhookConfigRendererProps) {
25
27
  const webhooks = data.webhooks || []
26
28
  const hasFailPolicy = webhooks.some((w: any) => w.failurePolicy === 'Fail')
27
29
  const allIgnore = webhooks.length > 0 && webhooks.every((w: any) => w.failurePolicy === 'Ignore')
@@ -71,7 +73,11 @@ export function WebhookConfigRenderer({ data, isMutating }: WebhookConfigRendere
71
73
  return (
72
74
  <div key={i} className="card-inner-lg">
73
75
  <div className="text-sm font-medium text-theme-text-primary">{wh.name}</div>
74
- <div className="text-xs text-theme-text-secondary mt-0.5">{target}</div>
76
+ <div className="text-xs text-theme-text-secondary mt-0.5">
77
+ {svc?.namespace && svc?.name ? (
78
+ <><span>Service: </span><ResourceLink name={svc.name} kind="services" namespace={svc.namespace} label={`${svc.namespace}/${svc.name}`} onNavigate={onNavigate} /><span>{svc.port ? `:${svc.port}` : ''}{svc.path || ''}</span></>
79
+ ) : target}
80
+ </div>
75
81
 
76
82
  {/* Policy badges */}
77
83
  <div className="flex flex-wrap gap-1.5 mt-2">