@skyhook-io/k8s-ui 1.18.2 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
- package/src/components/compare/ResourceCompareView.test.tsx +54 -0
- package/src/components/compare/ResourceCompareView.tsx +35 -12
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +26 -7
- package/src/components/gitops/RollbackDialog.test.tsx +80 -0
- package/src/components/gitops/RollbackDialog.tsx +25 -6
- package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
- package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
- package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
- package/src/components/gitops/applicationset-row.test.ts +12 -0
- package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
- package/src/components/gitops/detail-helpers.test.ts +8 -2
- package/src/components/gitops/detail-helpers.ts +9 -11
- package/src/components/gitops/index.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
- package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
- package/src/components/gitops/insights/insights-helpers.ts +17 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +15 -3
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +15 -0
- package/src/components/issues/types.ts +10 -0
- package/src/components/kueue/AdmissionPath.tsx +223 -0
- package/src/components/kueue/QueueEvidence.tsx +148 -0
- package/src/components/kueue/constants.ts +1 -0
- package/src/components/kueue/eligibility.ts +43 -0
- package/src/components/kueue/flavors-tried.ts +79 -0
- package/src/components/kueue/helpers.test.ts +105 -0
- package/src/components/kueue/policy.ts +100 -0
- package/src/components/kueue/queue-detail.test.tsx +317 -0
- package/src/components/kueue/queue-status.ts +42 -0
- package/src/components/kueue/related-read.ts +48 -0
- package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
- package/src/components/logs/StructuredLogLine.test.tsx +22 -0
- package/src/components/logs/StructuredLogLine.tsx +8 -3
- package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
- package/src/components/resources/KueueAdmissionSection.tsx +51 -12
- package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
- package/src/components/resources/KueuePendingPosition.tsx +91 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
- package/src/components/resources/ResourcesSidebar.tsx +3 -2
- package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
- package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
- package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
- package/src/components/resources/ResourcesView.tsx +50 -3
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +7 -0
- package/src/components/resources/get-pod-phase-display.test.ts +70 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
- package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
- package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
- package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/JobRenderer.tsx +7 -2
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
- package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
- package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
- package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
- package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
- package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
- package/src/components/resources/renderers/crd-cells.tsx +55 -0
- package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
- package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
- package/src/components/resources/renderers/index.ts +6 -0
- package/src/components/resources/renderers/kueue-cells.tsx +5 -0
- package/src/components/resources/resource-utils-argo.test.ts +94 -1
- package/src/components/resources/resource-utils-argo.ts +84 -5
- package/src/components/resources/resource-utils-crd.test.ts +32 -0
- package/src/components/resources/resource-utils-crd.ts +26 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
- package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
- package/src/components/resources/resource-utils-kueue.test.ts +15 -0
- package/src/components/resources/resource-utils-kueue.ts +137 -4
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
- package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
- package/src/components/resources/resource-utils-ray.test.ts +42 -1
- package/src/components/resources/resource-utils-ray.ts +38 -0
- package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
- package/src/components/resources/resource-utils-strimzi.ts +35 -0
- package/src/components/resources/resource-utils.ts +45 -1
- package/src/components/shared/ResourceActionsBar.tsx +83 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
- package/src/components/topology/K8sResourceNode.test.ts +14 -0
- package/src/components/topology/K8sResourceNode.tsx +18 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +96 -94
- package/src/components/topology/layout.ts +3 -1
- package/src/components/topology/pod-group-expansion.test.ts +38 -0
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/RadarUpgradeNote.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/PodList.tsx +35 -9
- package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
- package/src/components/workload/WorkloadView.tsx +61 -8
- package/src/components/workload/workload-logs-availability.test.ts +7 -0
- package/src/types/core.ts +41 -0
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +2 -2
- package/src/types/kueue-queues.ts +84 -0
- package/src/types/read-coverage.ts +2 -1
- package/src/types/scheduling.ts +72 -0
- package/src/utils/animation.test.ts +38 -0
- package/src/utils/animation.ts +34 -0
- package/src/utils/api-resources-crd.test.ts +11 -0
- package/src/utils/api-resources.ts +5 -1
- package/src/utils/applications.test.ts +15 -1
- package/src/utils/applications.ts +26 -1
- package/src/utils/crd-schema.test.ts +107 -0
- package/src/utils/crd-schema.ts +201 -0
- package/src/utils/format.test.ts +37 -1
- package/src/utils/format.ts +28 -80
- package/src/utils/gitops-owner.test.ts +28 -2
- package/src/utils/gitops-owner.ts +19 -0
- package/src/utils/kueue-queue-workloads.ts +19 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +90 -1
- package/src/utils/topology-neighborhood.ts +55 -3
- package/src/utils/workload-history-notes.test.ts +76 -0
- 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
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
|
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="
|
|
66
|
-
<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.
|
|
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
|
-
|
|
66
|
-
const
|
|
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
|
-
<
|
|
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">
|
|
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">
|