@skyhook-io/k8s-ui 1.14.12 → 1.16.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 (72) hide show
  1. package/package.json +3 -3
  2. package/src/components/audit/AuditFindingsTable.tsx +1 -1
  3. package/src/components/checks/ChecksView.tsx +8 -8
  4. package/src/components/compare/CompareResourcePicker.tsx +4 -3
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
  6. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
  7. package/src/components/gitops/RollbackDialog.tsx +4 -3
  8. package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
  9. package/src/components/gitops/health-provenance.test.ts +1 -1
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
  12. package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
  13. package/src/components/issues/IssuesView.tsx +2 -2
  14. package/src/components/logs/LogCore.tsx +131 -97
  15. package/src/components/logs/StructuredLogLine.tsx +18 -28
  16. package/src/components/logs/log-palette.ts +4 -0
  17. package/src/components/logs/useLogBuffer.ts +8 -39
  18. package/src/components/logs/useLogSearch.ts +52 -24
  19. package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
  20. package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
  21. package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
  22. package/src/components/resources/KueueAdmissionSection.tsx +140 -51
  23. package/src/components/resources/ResourcesView.tsx +4 -0
  24. package/src/components/resources/printer-columns.test.ts +1 -1
  25. package/src/components/resources/renderers/JobRenderer.tsx +5 -1
  26. package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
  27. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
  28. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
  29. package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
  30. package/src/components/resources/renderers/PodRenderer.tsx +10 -2
  31. package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
  32. package/src/components/resources/renderers/PolicySection.tsx +10 -1
  33. package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
  34. package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
  35. package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
  36. package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
  37. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
  39. package/src/components/resources/renderers/index.ts +2 -0
  40. package/src/components/resources/resource-utils-kueue.ts +14 -0
  41. package/src/components/resources/resource-utils-ray.ts +14 -4
  42. package/src/components/shared/CreateResourceDialog.tsx +6 -2
  43. package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
  44. package/src/components/shared/DrainPlanDialog.tsx +14 -3
  45. package/src/components/shared/ResourceActionsBar.tsx +18 -11
  46. package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
  47. package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
  48. package/src/components/shared/SetImageDialog.tsx +4 -2
  49. package/src/components/shared/revision-role-badges.test.ts +1 -1
  50. package/src/components/trace/reachGraphModel.test.ts +1 -0
  51. package/src/components/trace/reachInspector.test.ts +4 -9
  52. package/src/components/ui/ConfirmDialog.tsx +5 -3
  53. package/src/components/ui/DialogPortal.test.tsx +279 -0
  54. package/src/components/ui/DialogPortal.tsx +147 -9
  55. package/src/components/ui/FilterPill.tsx +1 -1
  56. package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
  57. package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
  58. package/src/components/ui/RadarUpgradeNote.tsx +129 -0
  59. package/src/components/ui/ResourceBar.tsx +12 -6
  60. package/src/components/ui/SelectMenu.tsx +62 -19
  61. package/src/components/ui/YamlReview.tsx +4 -1
  62. package/src/components/ui/drawer-components.test.tsx +14 -1
  63. package/src/components/ui/drawer-components.tsx +50 -40
  64. package/src/components/ui/index.ts +8 -0
  65. package/src/components/workload/WorkloadView.tsx +109 -3
  66. package/src/types/core.ts +24 -4
  67. package/src/types/fetch-error.ts +33 -0
  68. package/src/utils/log-format.ts +1 -1
  69. package/src/utils/log-level.test.ts +269 -0
  70. package/src/utils/log-level.ts +301 -0
  71. package/src/utils/resource-hierarchy.test.ts +69 -0
  72. package/src/utils/resource-hierarchy.ts +87 -52
@@ -1,87 +1,176 @@
1
- import { isKueueConditionStale } from './resource-utils-kueue'
1
+ import { admissionCheckSeverity, isKueueConditionStale } from './resource-utils-kueue'
2
+ import { Disclosure } from '../ui/Disclosure'
3
+ import { formatRelativeAgeTime } from '../../utils/format'
2
4
  import { Badge } from '../ui/Badge'
3
- import { AlertBanner, ConditionsSection, ResourceLink } from '../ui/drawer-components'
5
+ import { AlertBanner, ResourceLink, Section } from '../ui/drawer-components'
4
6
  import { kindToPluralWithGroup } from '../../utils/navigation'
5
- import type { KueueAdmissionResponse, SchedulingObservation, SchedulingRef } from '../../types/scheduling'
7
+ import type { KueueAdmissionResponse, SchedulingCondition, SchedulingObservation, SchedulingRef } from '../../types/scheduling'
6
8
 
7
9
  interface KueueAdmissionSectionProps {
10
+ presentation?: 'card' | 'drawer'
8
11
  data?: KueueAdmissionResponse
9
12
  loading: boolean
10
13
  error?: string
11
14
  forbidden?: boolean
12
15
  hinted: boolean
13
16
  externalExecution: boolean
17
+ hasOwner?: boolean
14
18
  onRetry?: () => void
15
19
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
16
20
  }
17
21
 
18
22
  const phases = { pending: 'Pending admission', quota_reserved: 'Quota reserved', admitted: 'Admitted', finished: 'Finished' }
19
23
 
20
- export function KueueAdmissionSection({ data, loading, error, forbidden, hinted, externalExecution, onRetry, onNavigate }: KueueAdmissionSectionProps) {
24
+ export function KueueAdmissionSection({ presentation = 'card', data, loading, error, forbidden, hinted, externalExecution, hasOwner, onRetry, onNavigate }: KueueAdmissionSectionProps) {
21
25
  if (!loading && !error && data && data.workloads.length === 0 && !hinted) return null
22
26
  const link = (name: string, ref?: SchedulingRef) => ref
23
27
  ? <ResourceLink {...ref} kind={kindToPluralWithGroup(ref.kind, ref.group ?? '')} label={name} onNavigate={onNavigate} />
24
28
  : name
25
- return (
26
- <section className="rounded-lg border border-theme-border bg-theme-surface p-4" aria-label="Kueue admission">
27
- <h3 className="text-sm font-semibold text-theme-text-primary">Kueue admission</h3>
28
- <p className="mt-1 text-xs text-theme-text-secondary">Admission and execution are separate observations. An admitted Workload does not prove that Jobs or Pods are running.</p>
29
- {loading ? <p className="mt-3 text-sm text-theme-text-secondary">Looking for controller-owned Workloads…</p>
30
- : error ? <div className="mt-3 [&>div]:mb-0"><AlertBanner variant={forbidden ? 'info' : 'warning'} title={forbidden ? 'Admission lookup requires permission' : 'Admission evidence unavailable'} message={error}>{!forbidden && onRetry && <button type="button" className="mt-2 text-accent-text hover:underline" onClick={onRetry}>Retry admission lookup</button>}</AlertBanner></div>
31
- : data && !data.installed ? <p className="mt-3 text-sm text-theme-text-secondary">Kueue Workloads are not served by this cluster.</p>
32
- : data && data.workloads.length === 0 ? <p className="mt-3 text-sm text-theme-text-secondary">No controller-owned Kueue Workload observed in this namespace.{externalExecution ? ' This JobSet uses an external controller; local absence does not establish remote admission or execution state.' : ' Queue metadata alone does not establish an admission decision.'}</p>
33
- : data && <div className="mt-3 space-y-3">
29
+ const spacing = presentation === 'drawer' ? '' : 'mt-3'
30
+ const content = <>
31
+ {loading ? <p className={`${spacing} text-sm text-theme-text-secondary`}>Looking for controller-owned Workloads…</p>
32
+ : error ? <div className={`${spacing} [&>div]:mb-0`}><AlertBanner variant={forbidden ? 'info' : 'warning'} title={forbidden ? 'Admission lookup requires permission' : 'Admission evidence unavailable'} message={error}>{!forbidden && onRetry && <button type="button" className="mt-2 text-accent-text hover:underline" onClick={onRetry}>Retry admission lookup</button>}</AlertBanner></div>
33
+ : data && !data.installed ? <p className={`${spacing} text-sm text-theme-text-secondary`}>Kueue Workloads are not served by this cluster.</p>
34
+ : data && data.workloads.length === 0 ? <p className={`${spacing} text-sm text-theme-text-secondary`}>No controller-owned Kueue Workload observed in this namespace.{hasOwner ? ' Admission may be tracked on the owning resource; use the existing owner link to inspect it.' : externalExecution ? ' This workload uses an external controller; local absence does not establish remote admission or execution state.' : ' Queue metadata alone does not establish an admission decision.'}</p>
35
+ : data && <div className={`${spacing} space-y-3`}>
34
36
  {data.total > 1 && <p className="text-xs text-theme-text-secondary">{data.truncated ? `${data.workloads.length} of ${data.total}` : data.total} Workloads shown, newest first. Each is a separate controller-owned record; order does not identify a current attempt.</p>}
35
- {data.workloads.map((workload) => <article key={workload.uid} className="card-inner space-y-2 text-sm">
36
- <div className="flex flex-wrap items-center gap-2"><span className="font-medium">{link(workload.name, workload.ref)}</span>{workload.deleting && <Badge severity="alert">Deleting</Badge>}<span className="text-xs text-theme-text-tertiary">Generation {workload.generation}</span></div>
37
+ {data.workloads.map((workload) => <article key={workload.uid} className="space-y-2 text-sm border-t border-theme-border pt-3 first:border-0 first:pt-0">
38
+ {data.total > 1 && workload.createdAt && <p className="text-xs text-theme-text-tertiary">Created <time dateTime={workload.createdAt} title={workload.createdAt}>{formatRelativeAgeTime(workload.createdAt)}</time></p>}
39
+ {workload.projection !== 'available' && <div className="flex flex-wrap items-center gap-2">{link(workload.name, workload.ref)}{workload.deleting && <Badge severity="alert">Deleting</Badge>}</div>}
37
40
  {workload.projection === 'unsupported' ? <p className="text-theme-text-secondary">Associated Workload found. Scheduling projection is unavailable for {workload.apiVersion}; inspect the resource for native evidence.</p>
38
41
  : workload.projection === 'forbidden' ? <p className="text-theme-text-secondary">Associated Workload found, but permission to get this Workload is required for its scheduling detail.</p>
39
- : workload.scheduling?.observations?.map((observation, index) => <AdmissionObservation key={index} observation={observation} link={link} />)}
42
+ : workload.scheduling?.observations?.map((observation, index) => <AdmissionObservation key={index} presentation={presentation} observation={observation} identity={link(workload.name, workload.ref)} sourceLink={workload.ref && onNavigate ? link("View Workload details", workload.ref) : undefined} generation={workload.generation} deleting={workload.deleting} link={link} />)}
40
43
  {workload.linksLimited && <p className="text-xs text-theme-text-tertiary">Some resource links are unavailable. Names already reported by the Workload remain visible.</p>}
41
44
  </article>)}
42
45
  </div>}
43
- </section>
44
- )
46
+ </>
47
+ return presentation === 'drawer'
48
+ ? <Section title="Kueue admission">{content}</Section>
49
+ : <section className="space-y-3" aria-label="Kueue admission"><h3 className="text-sm font-semibold text-theme-text-primary">Kueue admission</h3>{content}</section>
50
+ }
51
+
52
+ type Link = (name: string, ref?: SchedulingRef) => React.ReactNode
53
+
54
+
55
+ const evidenceCard = 'min-w-0 overflow-hidden rounded-lg border border-theme-border bg-theme-surface'
56
+ const cardHeader = 'border-b border-theme-border bg-theme-elevated px-3 py-2.5'
57
+ const groupHeading = 'text-xs font-medium uppercase tracking-wider text-theme-text-secondary'
58
+ type Gate = NonNullable<SchedulingObservation['gates']>[number]
59
+
60
+ function conditionLabel(condition: SchedulingCondition): string {
61
+ const { type, status } = condition
62
+ if (type === 'PodsReady') return `Pods ready: ${status === 'True' ? 'yes' : status === 'False' ? 'no' : status}`
63
+ if (type === 'WaitingForReplacementPods') return `Replacement Pods: ${status === 'True' ? 'waiting' : status === 'False' ? 'not waiting' : status}`
64
+ if (status === 'True') {
65
+ if (type === 'Evicted') return 'Evicted'
66
+ if (type === 'Preempted') return 'Preempted'
67
+ if (type === 'DeactivationTarget') return 'Deactivation requested'
68
+ }
69
+ return `${type}=${status}`
70
+ }
71
+
72
+ function ConditionEvidence({ condition, generation, showAge }: { condition: SchedulingCondition; generation?: number; showAge?: boolean }) {
73
+ return <div className="space-y-1 py-3 first:pt-0 last:pb-0">
74
+ <p className="max-w-3xl whitespace-pre-wrap break-words text-theme-text-secondary">
75
+ <span className="block font-medium text-theme-text-primary">{conditionLabel(condition)}{showAge && condition.lastTransitionTime && <span className="font-normal text-xs text-theme-text-tertiary"> · Status since {formatRelativeAgeTime(condition.lastTransitionTime)}</span>}</span>
76
+ {condition.message || condition.reason || `${condition.type}=${condition.status}`}
77
+ </p>
78
+ {isKueueConditionStale(condition, generation) && <p className="text-xs text-warning-text">Stale evidence: condition generation {condition.observedGeneration}; Workload generation {generation}.</p>}
79
+ </div>
80
+ }
81
+
82
+ function GateEvidence({ gate, link }: { gate: Gate; link: Link }) {
83
+ const retryMetadata = [gate.retryCount != null && gate.retryCount > 0 ? `Retries: ${gate.retryCount}` : null, gate.requeueAfterSeconds != null && gate.requeueAfterSeconds > 0 ? `Requeue delay: ${gate.requeueAfterSeconds}s` : null].filter(Boolean)
84
+ return <div className="space-y-2 py-3">
85
+ <div className="grid grid-cols-[minmax(0,1fr)_6rem] items-start gap-2">
86
+ <span className="min-w-0 break-words font-medium [&_button]:text-left">{link(gate.name, gate.ref)}</span>
87
+ <Badge className="justify-self-end" severity={gate.kind === 'preemption_gate' ? 'neutral' : admissionCheckSeverity(gate.nativeState)}>{gate.nativeState || 'Unknown'}</Badge>
88
+ </div>
89
+ {gate.lastTransitionTime && <p className="text-xs text-theme-text-tertiary">Status since <time dateTime={gate.lastTransitionTime} title={gate.lastTransitionTime}>{formatRelativeAgeTime(gate.lastTransitionTime)}</time></p>}
90
+ {gate.message && <p className="max-w-3xl whitespace-pre-wrap break-words">{gate.message}</p>}
91
+ {retryMetadata.length > 0 && <p className="text-xs text-theme-text-secondary">{retryMetadata.join(' · ')}</p>}
92
+ </div>
45
93
  }
46
94
 
47
- function AdmissionObservation({ observation, link }: { observation: SchedulingObservation; link: (name: string, ref?: SchedulingRef) => React.ReactNode }) {
95
+ function AdmissionObservation({ observation, identity, sourceLink, generation, deleting, presentation, link }: { observation: SchedulingObservation; identity: React.ReactNode; sourceLink?: React.ReactNode; generation: number; deleting: boolean; presentation: 'card' | 'drawer'; link: Link }) {
48
96
  const kueue = observation.kueue
49
97
  const condition = observation.primaryCondition
50
98
  const stale = isKueueConditionStale(condition, observation.subjectGeneration)
51
- const conditions = [condition, ...(observation.disruptions ?? []), kueue?.podsReady, kueue?.waitingForReplacementPods]
99
+ const secondary = [...(observation.disruptions ?? []), kueue?.podsReady, kueue?.waitingForReplacementPods]
52
100
  .filter((entry) => entry != null)
101
+ .filter((entry) => entry.type !== condition?.type)
53
102
  .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.type === entry.type) === index)
54
- return <div className="space-y-2">
55
- <div className="flex flex-wrap items-center gap-2">
56
- <Badge severity={stale || observation.decision === 'unknown' ? 'neutral' : observation.decision === 'satisfied' ? 'success' : 'warning'}>{kueue ? phases[kueue.phase] : 'Admission'}</Badge>
57
- <span className="text-theme-text-secondary">Decision: {observation.decision}</span>
58
- {kueue?.outcome && <span>Outcome: {kueue.outcome}</span>}
59
- {kueue?.active === false && <span>Workload inactive</span>}
103
+ const nominal = (entry: SchedulingCondition) => !isKueueConditionStale(entry, observation.subjectGeneration) && ((entry.type === 'PodsReady' && entry.status === 'True') || (entry.type === 'WaitingForReplacementPods' && entry.status === 'False'))
104
+ const supporting = secondary.filter(nominal)
105
+ const visible = secondary.filter((entry) => !nominal(entry))
106
+ const isDisruption = (entry: SchedulingCondition) => observation.disruptions?.some((item) => item.type === entry.type)
107
+ const disruptions = visible.filter(isDisruption)
108
+ const readiness = visible.filter((entry) => !isDisruption(entry))
109
+ const checks = (observation.gates ?? []).filter((gate) => gate.kind !== 'preemption_gate')
110
+ const pendingChecks = checks.filter((gate) => gate.nativeState !== 'Ready')
111
+ const readyChecks = checks.filter((gate) => gate.nativeState === 'Ready')
112
+ const preemption = (observation.gates ?? []).filter((gate) => gate.kind === 'preemption_gate')
113
+ const split = presentation === 'card' && (visible.length > 0 || preemption.length > 0)
114
+ const severity = stale || observation.decision === 'unknown' ? 'neutral'
115
+ : kueue?.phase === 'finished' ? kueue.outcome === 'failed' ? 'error' : kueue.outcome === 'succeeded' ? 'success' : 'neutral'
116
+ : observation.decision === 'satisfied' ? 'success' : 'warning'
117
+ const qualifier = observation.decision === 'held' && kueue?.active !== false ? 'Admission held' : observation.decision === 'unknown' ? 'Admission unknown' : null
118
+ return <div className="@container/admission space-y-4">
119
+ <div className={split ? 'grid min-w-0 items-start gap-4 @min-[1024px]/admission:grid-cols-[minmax(0,1.7fr)_minmax(320px,0.9fr)]' : 'space-y-4'}>
120
+ <div className="min-w-0 space-y-4">
121
+ <section aria-label="Admission" className={evidenceCard}>
122
+ <h4 className={`${groupHeading} ${cardHeader}`}>Admission</h4>
123
+ <div className="space-y-2 p-3">
124
+ <div className="flex flex-wrap items-center gap-2">
125
+ <span className="font-medium break-all">{identity}</span>
126
+ <Badge severity={severity}>{kueue ? phases[kueue.phase] : 'Admission'}</Badge>
127
+ {qualifier && <span className="text-xs text-theme-text-secondary">{qualifier}</span>}
128
+ {kueue?.active === false && <Badge severity="neutral">Workload inactive</Badge>}
129
+ {deleting && <Badge severity="alert">Deleting</Badge>}
130
+ {kueue?.outcome && <span>Outcome: {kueue.outcome}</span>}
131
+ </div>
132
+ {stale && <p className="text-xs text-warning-text">Stale evidence: condition generation {condition!.observedGeneration}; Workload generation {observation.subjectGeneration}.</p>}
133
+ {condition ? <p className="max-w-3xl whitespace-pre-wrap break-words">{condition.message || <><code>{condition.reason}</code>{condition.reason && ' · '}{condition.type}={condition.status}</>}</p> : <p className="text-theme-text-secondary">No primary admission condition reported.</p>}
134
+ {condition?.lastTransitionTime && <p className="text-xs text-theme-text-tertiary">{condition.type}={condition.status} since {formatRelativeAgeTime(condition.lastTransitionTime)}</p>}
135
+ <div className="empty:hidden flex flex-wrap gap-x-5 gap-y-2 border-t border-theme-border pt-2 text-xs text-theme-text-secondary">
136
+ {sourceLink && <span>{sourceLink}</span>}
137
+ {(!!observation.queues?.length || kueue?.concurrentAdmission) && <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-theme-text-secondary">
138
+ {observation.queues?.map((queue, index) => <span key={index}>{queue.roles.join(' + ').replace(/^./, (letter) => letter.toUpperCase())} queue: {link(queue.name, queue.ref)}</span>)}
139
+ {kueue?.concurrentAdmission && <span>Parent Workload: {link(kueue.concurrentAdmission.parentName, kueue.concurrentAdmission.parentRef)}</span>}
140
+ </div>}
141
+ {kueue?.requeueState && ((kueue.requeueState.count ?? 0) > 0 || kueue.requeueState.requeueAt) && <p className="text-xs text-theme-text-secondary">{kueue.requeueState.count != null && `Requeues: ${kueue.requeueState.count}`}{kueue.requeueState.requeueAt && `${kueue.requeueState.count != null ? ' · ' : ''}Eligible again: ${kueue.requeueState.requeueAt}`}</p>}
142
+ {(kueue?.phase === 'admitted' || kueue?.phase === 'quota_reserved') && <p className="text-xs text-theme-text-tertiary">Admission status; execution is shown separately.</p>}
143
+ </div>
144
+ </div>
145
+ <div className="border-t border-theme-border p-3">
146
+ <Disclosure summary={<span>Technical details{supporting.length > 0 && ` · ${supporting.length} additional condition${supporting.length === 1 ? '' : 's'}`}{readyChecks.length > 0 && ` · ${readyChecks.length} ready check${readyChecks.length === 1 ? '' : 's'}`}</span>} summaryClassName="text-xs text-theme-text-secondary">
147
+ <div className="space-y-2 pt-2 text-xs text-theme-text-secondary">
148
+ <p>Decision: {observation.decision} · Generation {generation}</p>
149
+ {[condition, ...secondary].filter((entry) => entry != null).map((entry) => <div key={entry.type}>
150
+ <p>{entry.type}={entry.status}{entry.reason && ` · Reason: ${entry.reason}`}{entry.observedGeneration != null && ` · Observed generation ${entry.observedGeneration}`}{entry.lastTransitionTime && ` · Status since ${formatRelativeAgeTime(entry.lastTransitionTime)}`}</p>
151
+ {supporting.includes(entry) && entry.message && <p className="whitespace-pre-wrap break-words">{entry.message}</p>}
152
+ </div>)}
153
+ {(observation.gates ?? []).filter((gate) => gate.retryCount === 0 || gate.requeueAfterSeconds === 0).map((gate, index) => <p key={index}>{gate.kind === 'preemption_gate' ? 'Preemption gate' : 'Admission check'} {gate.name}{gate.retryCount === 0 && ' · Retries: 0'}{gate.requeueAfterSeconds === 0 && ' · Requeue delay: 0s'}</p>)}
154
+ {kueue?.requeueState?.count === 0 && !kueue.requeueState.requeueAt && <p>Requeues: 0</p>}
155
+ {readyChecks.map((gate, index) => <GateEvidence key={index} gate={gate} link={link} />)}
156
+ </div>
157
+ </Disclosure>
158
+ </div>
159
+ </section>
160
+ {pendingChecks.length > 0 && <section aria-label="Admission checks" className={evidenceCard}>
161
+ <h4 className={`${groupHeading} ${cardHeader}`}>Admission checks · {pendingChecks.length} not ready</h4>
162
+ <div className="divide-y divide-theme-border [&>div]:p-3">{pendingChecks.map((gate, index) => <GateEvidence key={index} gate={gate} link={link} />)}</div>
163
+ </section>}
60
164
  </div>
61
- {stale && <p className="text-sm text-theme-text-secondary">The primary condition describes generation {condition!.observedGeneration}; this Workload is now generation {observation.subjectGeneration}. Treat that condition as stale evidence.</p>}
62
- {condition && <p className="text-xs text-theme-text-secondary">Selected admission evidence · Primary condition: {condition.type}</p>}
63
- {!condition && <p className="text-theme-text-secondary">No primary admission condition reported.</p>}
64
- <ConditionsSection
65
- conditions={conditions}
66
- defaultExpanded={true}
67
- getConditionTone={(entry) => {
68
- if (entry.status !== 'True' && entry.status !== 'False') return 'unknown'
69
- if (isKueueConditionStale(entry, observation.subjectGeneration)) return 'unknown'
70
- if (entry.type === 'Finished') return entry.status === 'False' ? 'unknown' : kueue?.outcome === 'failed' ? 'fail' : kueue?.outcome === 'succeeded' ? 'ok' : 'unknown'
71
- if (entry.type === condition?.type) return observation.decision === 'satisfied' ? 'ok' : observation.decision === 'unknown' ? 'unknown' : 'warning'
72
- if (entry.type === 'WaitingForReplacementPods' || observation.disruptions?.some((item) => item.type === entry.type)) return entry.status === 'True' ? 'warning' : 'ok'
73
- return entry.status === 'True' ? 'ok' : 'warning'
74
- }}
75
- />
76
- {!!observation.queues?.length && <div className="flex flex-wrap gap-x-5 gap-y-1 text-xs">{observation.queues.map((queue, index) => <span key={index}>{queue.roles.join(' + ')} queue: {link(queue.name, queue.ref)}</span>)}</div>}
77
- {observation.gates?.map((gate, index) => <div key={index} className="border-l-2 border-theme-border pl-3 text-xs text-theme-text-secondary">
78
- <p>{gate.kind === 'preemption_gate' ? 'Preemption gate' : 'Admission check'}: {link(gate.name, gate.ref)} · {gate.nativeState || gate.decision}</p>
79
- {gate.message && <p className="whitespace-pre-wrap break-words">{gate.message}</p>}
80
- {gate.kind === 'preemption_gate' && <p>This gate governs preemption; it alone does not establish an admission blocker.</p>}
81
- {gate.retryCount != null && <p>Retries: {gate.retryCount}</p>}
82
- {gate.requeueAfterSeconds != null && <p>Requeue delay: {gate.requeueAfterSeconds}s</p>}
83
- </div>)}
84
- {kueue?.requeueState && <p className="text-xs text-theme-text-secondary">Requeues: {kueue.requeueState.count ?? 'Not reported'}{kueue.requeueState.requeueAt && ` · Eligible again: ${kueue.requeueState.requeueAt}`}</p>}
85
- {kueue?.concurrentAdmission && <p className="text-xs">Parent Workload: {link(kueue.concurrentAdmission.parentName, kueue.concurrentAdmission.parentRef)}</p>}
165
+ {(visible.length > 0 || preemption.length > 0) && <section aria-label="Supporting evidence" className={`${evidenceCard} self-start`}>
166
+ <h4 className={`${groupHeading} ${cardHeader}`}>Supporting evidence</h4>
167
+ <div className="divide-y divide-theme-border px-3">
168
+ {disruptions.length > 0 && <div className="space-y-3 py-3"><h5 className={groupHeading}>Reported disruptions · {disruptions.length}</h5><div className="divide-y divide-theme-border">{disruptions.map((entry) => <ConditionEvidence key={entry.type} condition={entry} generation={observation.subjectGeneration} showAge />)}</div></div>}
169
+ {readiness.length > 0 && <div className="space-y-3 py-3"><h5 className={groupHeading}>Pod readiness · {readiness.length}</h5><div className="divide-y divide-theme-border">{readiness.map((entry) => <ConditionEvidence key={entry.type} condition={entry} generation={observation.subjectGeneration} />)}</div></div>}
170
+ {preemption.length > 0 && <div className="space-y-2 py-3"><h5 className={groupHeading}>Preemption gates · {preemption.length}</h5><p className="text-xs text-theme-text-tertiary">Governs preemption; it alone does not establish an admission blocker.</p><div className="divide-y divide-theme-border">{preemption.map((gate, index) => <GateEvidence key={index} gate={gate} link={link} />)}</div></div>}
171
+ </div>
172
+ </section>}
173
+ </div>
174
+
86
175
  </div>
87
176
  }
@@ -6243,6 +6243,7 @@ export function ResourcesView({
6243
6243
  {onCreateResource && (
6244
6244
  <Tooltip content={`Create ${selectedKind.kind || 'resource'}`}>
6245
6245
  <button
6246
+ aria-label={`Create ${selectedKind.kind || 'resource'}`}
6246
6247
  onClick={() => onCreateResource(selectedKind)}
6247
6248
  className="p-2 hover:bg-theme-elevated rounded-lg text-theme-text-secondary hover:text-theme-text-primary transition-colors"
6248
6249
  >
@@ -6846,6 +6847,7 @@ export function ResourcesView({
6846
6847
  <div className="flex items-center justify-center gap-3">
6847
6848
  <button
6848
6849
  type="button"
6850
+ aria-label="Decrease replicas"
6849
6851
  onClick={() => setBulkScaleReplicas(Math.max(0, bulkScaleReplicas - 1))}
6850
6852
  disabled={bulkScaleReplicas <= 0}
6851
6853
  className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -6857,12 +6859,14 @@ export function ResourcesView({
6857
6859
  min="0"
6858
6860
  max="10000"
6859
6861
  value={bulkScaleReplicas}
6862
+ aria-label="Replicas"
6860
6863
  onChange={(e) => setBulkScaleReplicas(Math.min(10000, Math.max(0, Number.parseInt(e.target.value, 10) || 0)))}
6861
6864
  className="w-24 text-center text-2xl font-semibold bg-theme-elevated border border-theme-border rounded-lg py-2 text-theme-text-primary focus:outline-none focus:border-skyhook-500"
6862
6865
  autoFocus
6863
6866
  />
6864
6867
  <button
6865
6868
  type="button"
6869
+ aria-label="Increase replicas"
6866
6870
  onClick={() => setBulkScaleReplicas(Math.min(10000, bulkScaleReplicas + 1))}
6867
6871
  disabled={bulkScaleReplicas >= 10000}
6868
6872
  className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -113,7 +113,7 @@ describe('formatPrinterCell', () => {
113
113
  describe('printerCellSortValue', () => {
114
114
  // Sorting must use the value, not its rendered text, or 10 sorts before 9.
115
115
  it('sorts numbers numerically', () => {
116
- expect([10, 9, 100].map(printerCellSortValue).sort((a, b) => (a as number) - (b as number)))
116
+ expect([10, 9, 100].map(v => printerCellSortValue(v)).sort((a, b) => (a as number) - (b as number)))
117
117
  .toEqual([9, 10, 100])
118
118
  })
119
119
  it('sorts booleans by truth and falls back to empty for anything else', () => {
@@ -1,9 +1,11 @@
1
+ import type { ReactNode } from 'react'
1
2
  import { Clock } from 'lucide-react'
2
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
3
4
  import { formatDuration } from '../resource-utils'
4
5
 
5
6
  interface JobRendererProps {
6
7
  data: any
8
+ admissionContent?: ReactNode
7
9
  }
8
10
 
9
11
  // Extract problems from Job status and conditions
@@ -42,7 +44,7 @@ function getJobProblems(data: any): string[] {
42
44
  return problems
43
45
  }
44
46
 
45
- export function JobRenderer({ data }: JobRendererProps) {
47
+ export function JobRenderer({ data, admissionContent }: JobRendererProps) {
46
48
  const status = data.status || {}
47
49
  const spec = data.spec || {}
48
50
  const conditions = status.conditions || []
@@ -82,6 +84,8 @@ export function JobRenderer({ data }: JobRendererProps) {
82
84
  <AlertBanner variant="success" title="Job Completed Successfully" />
83
85
  )}
84
86
 
87
+ {admissionContent}
88
+
85
89
  <Section title="Status" icon={Clock}>
86
90
  <PropertyList>
87
91
  <Property label="Succeeded" value={status.succeeded || 0} />
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react'
1
2
  import { Activity, Boxes, Network as NetworkIcon, ShieldCheck } from 'lucide-react'
2
3
  import {
3
4
  ConditionsSection,
@@ -13,6 +14,7 @@ import { getJobSetStatus } from '../resource-utils-jobset-lws'
13
14
 
14
15
  interface JobSetRendererProps {
15
16
  data: any
17
+ admissionContent?: ReactNode
16
18
  mode?: 'detail' | 'overview' | 'configuration'
17
19
  shownMemberCounts?: ReadonlyMap<string, number>
18
20
  onSelectRole?: (role: string) => void
@@ -50,7 +52,7 @@ function sumCountArray(value: unknown): number | undefined {
50
52
  return value.reduce((total: number, count: unknown) => total + (typeof count === 'number' ? count : 0), 0)
51
53
  }
52
54
 
53
- export function JobSetRenderer({ data, mode = 'detail', shownMemberCounts, onSelectRole }: JobSetRendererProps) {
55
+ export function JobSetRenderer({ data, admissionContent, mode = 'detail', shownMemberCounts, onSelectRole }: JobSetRendererProps) {
54
56
  const spec = data?.spec || {}
55
57
  const status = data?.status || {}
56
58
  const conditions: any[] = Array.isArray(status.conditions) ? status.conditions : []
@@ -85,6 +87,7 @@ export function JobSetRenderer({ data, mode = 'detail', shownMemberCounts, onSel
85
87
  return (
86
88
  <>
87
89
  {showSummary && <ProblemAlerts problems={problems} />}
90
+ {showSummary && admissionContent}
88
91
 
89
92
  {showSummary && externalController && (
90
93
  <div className="mb-3 rounded border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
@@ -5,7 +5,7 @@ import { expect, it, vi } from 'vitest'
5
5
  import { ClusterQueueRenderer, LocalQueueRenderer } from './KueueQueueRenderers'
6
6
 
7
7
  it('navigates from a namespaced LocalQueue to exact cluster-scoped Kueue dependencies', async () => {
8
- globalThis.IS_REACT_ACT_ENVIRONMENT = true
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
9
9
  const container = document.createElement('div')
10
10
  const root = createRoot(container)
11
11
  const onNavigate = vi.fn()
@@ -64,6 +64,6 @@ it('navigates from a namespaced LocalQueue to exact cluster-scoped Kueue depende
64
64
  })
65
65
  } finally {
66
66
  await act(async () => root.unmount())
67
- globalThis.IS_REACT_ACT_ENVIRONMENT = false
67
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: false })
68
68
  }
69
69
  })
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { Cpu, Gauge, ListChecks } from 'lucide-react'
3
3
  import { formatRelativeAgeTime } from '../../../utils/format'
4
- import { Badge, type BadgeSeverity } from '../../ui/Badge'
4
+ import { Badge } from '../../ui/Badge'
5
5
  import {
6
6
  AlertBanner,
7
7
  ConditionsSection,
@@ -12,7 +12,7 @@ import {
12
12
  type ConditionTone,
13
13
  } from '../../ui/drawer-components'
14
14
  import { formatResources } from '../resource-utils'
15
- import { getKueueWorkloadStatus, getKueueWorkloadPriority, getKueueWorkloadStatusCondition, isKueueWorkloadFailureReason, isKueueConditionStale } from '../resource-utils-kueue'
15
+ import { admissionCheckSeverity, getKueueWorkloadStatus, getKueueWorkloadPriority, getKueueWorkloadStatusCondition, isKueueWorkloadFailureReason, isKueueConditionStale } from '../resource-utils-kueue'
16
16
 
17
17
  const KUEUE_GROUP = 'kueue.x-k8s.io'
18
18
 
@@ -22,19 +22,6 @@ interface KueueWorkloadRendererProps {
22
22
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
23
23
  }
24
24
 
25
- function admissionCheckSeverity(state: string): BadgeSeverity {
26
- switch (state) {
27
- case 'Ready':
28
- return 'success'
29
- case 'Rejected':
30
- return 'error'
31
- case 'Retry':
32
- return 'warning'
33
- default:
34
- return 'neutral'
35
- }
36
- }
37
-
38
25
  function resourceList(resources: any): string | null {
39
26
  if (!resources || typeof resources !== 'object' || Object.keys(resources).length === 0) return null
40
27
  return formatResources(Object.fromEntries(Object.entries(resources).map(([name, quantity]) => [name, String(quantity)])))
@@ -435,3 +435,18 @@ describe('PodRenderer metrics', () => {
435
435
  expect(html).toContain('CPU')
436
436
  })
437
437
  })
438
+
439
+ describe('PodRenderer on a Radar without variable sources', () => {
440
+ it('falls back to the spec view with an upgrade note, not an error', () => {
441
+ const unsupported = Object.assign(new Error('unsupported'), {
442
+ radarUpgrade: { feature: 'Values from ConfigMaps and Secrets', minimumVersion: 'v1.9.0', currentVersion: 'v1.7.2' },
443
+ })
444
+ const html = renderToString(
445
+ <PodRenderer data={pod} onCopy={() => undefined} copied={null} environmentError={unsupported} />,
446
+ )
447
+ expect(html).toContain('Values from ConfigMaps and Secrets')
448
+ expect(html).toContain('need Radar v1.9 or newer on this cluster. It runs v1.7.2.')
449
+ expect(html).toContain('ConfigMap')
450
+ expect(html).not.toContain('could not be loaded')
451
+ })
452
+ })
@@ -23,6 +23,8 @@ import type {
23
23
  RBACPolicyRule,
24
24
  } from '../../../types'
25
25
  import { Tooltip } from '../../ui/Tooltip'
26
+ import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
27
+ import { getRadarUpgradeRequirement, type RadarUpgradeRequirement } from '../../../types/fetch-error'
26
28
  import { MetricsChart } from '../../ui/MetricsChart'
27
29
  import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
28
30
  import { ContainerEnvironmentSection } from './ContainerEnvironmentSection'
@@ -191,10 +193,12 @@ function EnvVarsSection({
191
193
  initContainers,
192
194
  containers,
193
195
  resolvedEnvFrom,
196
+ upgrade,
194
197
  }: {
195
198
  initContainers: any[]
196
199
  containers: any[]
197
200
  resolvedEnvFrom?: ResolvedEnvFrom
201
+ upgrade?: RadarUpgradeRequirement | null
198
202
  }) {
199
203
  const allContainers = [...initContainers, ...containers]
200
204
  const containersWithEnv = allContainers.filter((c: any) => c.env?.length > 0 || c.envFrom?.length > 0)
@@ -205,6 +209,7 @@ function EnvVarsSection({
205
209
  return (
206
210
  <Section title={`Environment Variables · ${subtitle}`} icon={List} defaultExpanded={false}>
207
211
  <div className="space-y-4">
212
+ {upgrade && <RadarUpgradeNote requirement={upgrade} />}
208
213
  {allContainers.map((container: any) => {
209
214
  const envVars: any[] = container.env || []
210
215
  const envFrom: any[] = container.envFrom || []
@@ -312,6 +317,8 @@ export function PodRenderer({
312
317
  const hasResolvedEnvironmentRows = environment?.containers.some(
313
318
  container => container.rows.length > 0 || container.truncated,
314
319
  ) ?? false
320
+ // An older Radar can't resolve variable sources; fall back to the spec view.
321
+ const environmentUpgrade = getRadarUpgradeRequirement(environmentError)
315
322
 
316
323
  const namespace = data.metadata?.namespace
317
324
  const podName = data.metadata?.name
@@ -792,7 +799,7 @@ export function PodRenderer({
792
799
  <div className="text-xs text-theme-text-tertiary">Loading variable sources…</div>
793
800
  </Section>
794
801
  )}
795
- {hasEnvironmentDeclarations && environmentError && !environment && (
802
+ {hasEnvironmentDeclarations && environmentError && !environment && !environmentUpgrade && (
796
803
  <Section title="Environment Variables" icon={List} defaultExpanded={false}>
797
804
  <div className="space-y-2 text-xs">
798
805
  <p className="text-theme-text-secondary">Variable sources could not be loaded.</p>
@@ -800,11 +807,12 @@ export function PodRenderer({
800
807
  </div>
801
808
  </Section>
802
809
  )}
803
- {(!environment || !hasResolvedEnvironmentRows) && !environmentLoading && !environmentError && hasEnvironmentDeclarations && (
810
+ {(!environment || !hasResolvedEnvironmentRows) && !environmentLoading && (!environmentError || environmentUpgrade) && hasEnvironmentDeclarations && (
804
811
  <EnvVarsSection
805
812
  initContainers={initContainers}
806
813
  containers={containers}
807
814
  resolvedEnvFrom={resolvedEnvFrom}
815
+ upgrade={environmentUpgrade}
808
816
  />
809
817
  )}
810
818
 
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
3
  import { PolicySection } from './PolicySection'
4
+ import { RadarUpgradeContext } from '../../ui/RadarUpgradeNote'
4
5
  import type { PolicyResourceResponse } from '../../../types/policy'
5
6
 
6
7
  const base: PolicyResourceResponse = {
@@ -157,3 +158,44 @@ describe('when results were withheld from this caller', () => {
157
158
  expect(html).not.toContain('not shown here')
158
159
  })
159
160
  })
161
+
162
+ // An older in-cluster Radar behind a newer frontend (Radar Hub) has no policy
163
+ // endpoint. That is an expected state, so it gets an upgrade prompt, not red.
164
+ describe('PolicySection — connected Radar predates policy results', () => {
165
+ const unsupported = Object.assign(new Error('Policy results need Radar v1.10.0 or newer'), {
166
+ radarUpgrade: { feature: 'Policy results', minimumVersion: 'v1.10.0', currentVersion: 'v1.7.2' },
167
+ })
168
+
169
+ it('prompts an upgrade instead of reporting a failure', () => {
170
+ const html = renderToString(<PolicySection data={null} error={unsupported} />)
171
+ expect(html).toContain('Policy results')
172
+ expect(html).toContain('need Radar v1.10 or newer on this cluster. It runs v1.7.2.')
173
+ expect(html).not.toContain('Could not load policy results')
174
+ expect(html).not.toContain('text-red-400')
175
+ })
176
+
177
+ it('hands the upgrade to the host when it offers a flow', () => {
178
+ const html = renderToString(
179
+ <RadarUpgradeContext.Provider value={{ onRequestUpgrade: () => undefined }}>
180
+ <PolicySection data={null} error={unsupported} />
181
+ </RadarUpgradeContext.Provider>,
182
+ )
183
+ expect(html).toContain('<button')
184
+ expect(html).toContain('Upgrade Radar')
185
+ })
186
+
187
+ it('links to instructions when the host has no flow', () => {
188
+ const html = renderToString(
189
+ <RadarUpgradeContext.Provider value={{ upgradeHref: 'https://radarhq.io/docs/configuration/in-cluster' }}>
190
+ <PolicySection data={null} error={unsupported} />
191
+ </RadarUpgradeContext.Provider>,
192
+ )
193
+ expect(html).toContain('href="https://radarhq.io/docs/configuration/in-cluster"')
194
+ expect(html).toContain('How to upgrade')
195
+ })
196
+
197
+ it('keeps a genuine failure red', () => {
198
+ const html = renderToString(<PolicySection data={null} error={new Error('HTTP 502 (Bad Gateway)')} />)
199
+ expect(html).toContain('Could not load policy results: HTTP 502 (Bad Gateway)')
200
+ })
201
+ })
@@ -2,7 +2,8 @@ import { ShieldCheck, ShieldAlert, ShieldQuestion } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section } from '../../ui/drawer-components'
4
4
  import { SEVERITY_BADGE } from '../../../utils/badge-colors'
5
- import { isForbiddenError } from '../../../types/fetch-error'
5
+ import { RadarUpgradeNote } from '../../ui/RadarUpgradeNote'
6
+ import { getRadarUpgradeRequirement, isForbiddenError } from '../../../types/fetch-error'
6
7
  import type { PolicyResourceResponse, PolicyResourceFinding } from '../../../types/policy'
7
8
 
8
9
  interface PolicySectionProps {
@@ -61,6 +62,14 @@ export function PolicySection({ data, loading, error }: PolicySectionProps) {
61
62
  </Section>
62
63
  )
63
64
  }
65
+ const upgrade = getRadarUpgradeRequirement(error)
66
+ if (upgrade) {
67
+ return (
68
+ <Section title={TITLE} icon={ShieldQuestion}>
69
+ <RadarUpgradeNote requirement={upgrade} />
70
+ </Section>
71
+ )
72
+ }
64
73
  return (
65
74
  <Section title={TITLE} icon={ShieldAlert}>
66
75
  <div className="text-sm text-red-400">{`Could not load policy results: ${error.message}`}</div>
@@ -41,9 +41,9 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
41
41
  const scope = selection.workerGroup != null ? `Worker group: ${selection.workerGroup}` : selection.nodeType === 'head' ? 'Head' : 'All runtime Pods'
42
42
  const select = (value: RayClusterPodSelection) => { setExpanded(false); onSelectPods?.(value) }
43
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} />
44
- return <>
44
+ return <div className="space-y-4">
45
45
  {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
- <Section title="Runtime Health" icon={Activity} defaultExpanded>
46
+ <Section variant="card" title="Runtime Health" icon={Activity} defaultExpanded>
47
47
  <PropertyList>
48
48
  <Property label="Ray Version" value={spec.rayVersion || 'Not specified'} />
49
49
  <Property label="Head" value={<Badge severity={stale ? 'neutral' : head?.status === 'True' ? 'success' : head?.status === 'False' ? 'warning' : 'neutral'}>{head?.status === 'True' ? 'Ready' : head?.status === 'False' ? 'Not ready' : 'Not reported'}{stale ? ' (stale)' : ''}</Badge>} />
@@ -58,7 +58,7 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
58
58
  {status.observedGeneration == null && <p className="mt-2 text-xs text-theme-text-tertiary">Observed generation is not reported.</p>}
59
59
  {conditions.some((c: any) => c.type === 'RayClusterProvisioned' && c.status === 'True') && <p className="mt-2 text-xs text-theme-text-tertiary">Provisioned records initial provisioning, not continuing runtime health.</p>}
60
60
  </Section>
61
- {onSelectPods && <div ref={podsRef}><Section title="Runtime Pods" icon={Server} defaultExpanded>
61
+ {onSelectPods && <div ref={podsRef}><Section variant="card" title="Runtime Pods" icon={Server} defaultExpanded>
62
62
  <SelectMenu ariaLabel="Pod scope" className="mb-3" searchPlaceholder="Find a worker group…"
63
63
  value={selection.workerGroup != null ? `group:${selection.workerGroup}` : selection.nodeType === 'head' ? 'head' : 'all'}
64
64
  options={[{ value: 'all', label: 'All runtime Pods' }, { value: 'head', label: 'Head' }, ...groups.map((g: any) => ({ value: `group:${g.groupName}`, label: `Worker group: ${g.groupName}` }))]}
@@ -70,7 +70,7 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
70
70
  <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>
71
71
  </> : <p className="text-sm text-theme-text-secondary">Pod evidence is not available.</p>}
72
72
  </Section></div>}
73
- <Section title={`Worker Groups (${groups.length})`} icon={Boxes} defaultExpanded>
73
+ <Section variant="card" title={`Worker Groups (${groups.length})`} icon={Boxes} defaultExpanded>
74
74
  <p className="mb-3 text-xs text-theme-text-tertiary">Declared sizing, not observed group health. Replicas can span multiple hosts; controller-reported worker counts above are Pods. In-tree autoscaling: {spec.enableInTreeAutoscaling === true ? 'enabled' : 'disabled'}.</p>
75
75
  <div className="space-y-3">{groups.map((group: any) => <div className="card-inner min-w-0" key={group.groupName}>
76
76
  <div className="mb-2 flex items-start justify-between gap-3"><span className="break-all text-sm font-medium text-theme-text-primary">{group.groupName}</span>{onSelectPods && <button className="shrink-0 text-xs text-accent-text hover:underline" onClick={() => { select({ nodeType: 'worker', workerGroup: group.groupName }); podsRef.current?.scrollIntoView({ block: 'start' }) }}>View Pods</button>}</div>
@@ -83,11 +83,11 @@ export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading,
83
83
  </div>)}</div>
84
84
  {groups.length === 0 && <p className="text-sm text-theme-text-secondary">No worker groups declared.</p>}
85
85
  </Section>
86
- {(owners.length > 0 || status.head?.podName || status.head?.serviceName) && <Section title="References" defaultExpanded><PropertyList>
86
+ {(owners.length > 0 || status.head?.podName || status.head?.serviceName) && <Section variant="card" title="References" defaultExpanded><PropertyList>
87
87
  {owners.map((owner: any) => <Property key={owner.uid} label="Controller" value={<ResourceLink kind={owner.kind === 'RayService' ? 'rayservices' : 'rayjobs'} group="ray.io" namespace={data.metadata.namespace} name={owner.name} onNavigate={onNavigate} />} />)}
88
88
  {status.head?.podName && <Property label="Reported Head Pod" value={<ResourceLink kind="pods" namespace={data.metadata.namespace} name={status.head.podName} onNavigate={onNavigate} />} />}
89
89
  {status.head?.serviceName && <Property label="Reported Head Service" value={<ResourceLink kind="services" namespace={data.metadata.namespace} name={status.head.serviceName} onNavigate={onNavigate} />} />}
90
90
  </PropertyList></Section>}
91
- <ConditionsSection conditions={conditions} getConditionTone={c => stale ? 'unknown' : rayClusterConditionTone(c)} defaultExpanded />
92
- </>
91
+ <ConditionsSection variant="card" conditions={conditions} getConditionTone={c => stale ? 'unknown' : rayClusterConditionTone(c)} defaultExpanded />
92
+ </div>
93
93
  }