@skyhook-io/k8s-ui 1.14.11 → 1.15.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 (37) hide show
  1. package/package.json +2 -1
  2. package/src/components/compare/CompareResourcePicker.tsx +4 -3
  3. package/src/components/gitops/RollbackDialog.tsx +4 -3
  4. package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
  5. package/src/components/gitops/health-provenance.test.ts +1 -1
  6. package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
  7. package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
  8. package/src/components/resources/KueueAdmissionSection.tsx +140 -51
  9. package/src/components/resources/ResourcesView.tsx +4 -0
  10. package/src/components/resources/printer-columns.test.ts +1 -1
  11. package/src/components/resources/renderers/JobRenderer.tsx +5 -1
  12. package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
  13. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
  14. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
  15. package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
  16. package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
  17. package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
  18. package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
  19. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  20. package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
  21. package/src/components/resources/renderers/index.ts +2 -0
  22. package/src/components/resources/resource-utils-kueue.ts +14 -0
  23. package/src/components/resources/resource-utils-ray.ts +14 -4
  24. package/src/components/shared/CreateResourceDialog.tsx +6 -2
  25. package/src/components/shared/ResourceActionsBar.tsx +4 -2
  26. package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
  27. package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
  28. package/src/components/shared/SetImageDialog.tsx +4 -2
  29. package/src/components/shared/revision-role-badges.test.ts +1 -1
  30. package/src/components/trace/reachGraphModel.test.ts +1 -0
  31. package/src/components/trace/reachInspector.test.ts +4 -9
  32. package/src/components/ui/ConfirmDialog.tsx +5 -3
  33. package/src/components/ui/DialogPortal.test.tsx +279 -0
  34. package/src/components/ui/DialogPortal.tsx +147 -9
  35. package/src/components/ui/YamlReview.tsx +4 -1
  36. package/src/components/ui/drawer-components.test.tsx +14 -1
  37. package/src/components/ui/drawer-components.tsx +50 -40
@@ -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)])))
@@ -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
  }
@@ -0,0 +1,71 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Badge } from '../../ui/Badge'
4
+ import { RayJobRenderer } from './RayJobRenderer'
5
+ import { getRayJobStatus } from '../resource-utils-ray'
6
+ import { RayJobCell } from './ray-cells'
7
+ import { getResourceStatus } from '../../shared/ResourceRendererDispatch'
8
+
9
+ const root = { apiVersion: 'ray.io/v1', kind: 'RayJob', metadata: { name: 'train', namespace: 'ml' }, spec: {} }
10
+ const render = (data: any) => renderToStaticMarkup(<RayJobRenderer data={data} onNavigate={() => {}} />)
11
+
12
+ describe('RayJob native evidence', () => {
13
+ it.each(['Retrying', 'Suspending', 'Suspended', 'Failed', 'ValidationFailed'])('prioritizes lifecycle %s across summary, table and header', jobDeploymentStatus => {
14
+ const data = { ...root, status: { jobDeploymentStatus, jobStatus: 'RUNNING' } }
15
+ expect(getRayJobStatus(data).text).toBe(jobDeploymentStatus)
16
+ expect(getResourceStatus('rayjobs', data)?.text).toBe(jobDeploymentStatus)
17
+ expect(renderToStaticMarkup(<RayJobCell resource={data} column="status" />)).toContain(jobDeploymentStatus)
18
+ expect(render(data)).toContain('Last reported application state')
19
+ expect(render(data)).toContain('RUNNING')
20
+ })
21
+ it('shows completed suspension with a neutral lifecycle badge', () => {
22
+ expect(render({ ...root, status: { jobDeploymentStatus: 'Suspended' } })).toContain(renderToStaticMarkup(<Badge severity="neutral">Suspended</Badge>))
23
+ })
24
+ it('does not call stopped completion success or missing status healthy', () => {
25
+ expect(getRayJobStatus({ status: { jobStatus: 'STOPPED', jobDeploymentStatus: 'Complete' } }).text).toBe('Stopped')
26
+ expect(render(root)).toContain('Not reported')
27
+ expect(render(root)).not.toContain('Succeeded')
28
+ expect(render({ ...root, status: { jobDeploymentStatus: 'Failed', jobStatus: 'STOPPED' } })).not.toContain('does not establish')
29
+ })
30
+ it('keeps application and controller times separate and clears absent evidence', () => {
31
+ const html = render({ ...root, status: { startTime: '2026-09-20T00:00:00Z', rayJobInfo: { startTime: '2026-09-20T00:01:00Z' }, message: 'capacity timeout', reason: 'DeadlineExceeded' } })
32
+ expect(html).toContain('Application started')
33
+ expect(html).toContain('Controller attempt started')
34
+ expect(html).toContain('capacity timeout')
35
+ expect(render({ ...root, status: { jobDeploymentStatus: 'Suspended' } })).not.toContain('Application started')
36
+ })
37
+ it.each(['HTTPMode', 'InteractiveMode', 'SidecarMode', 'NewMode'])('never invents a submitter for %s', submissionMode => {
38
+ const html = render({ ...root, spec: { submissionMode } })
39
+ expect(html).not.toContain('Observed submitter Job')
40
+ expect(html).toContain(submissionMode)
41
+ if (submissionMode === 'InteractiveMode') expect(html).toContain('spec.jobId')
42
+ })
43
+ it('does not link remote runtime names locally', () => {
44
+ const html = render({ ...root, spec: { managedBy: 'kueue.x-k8s.io/multikueue' }, status: { rayClusterName: 'remote-runtime' } })
45
+ expect(html).toContain('remote-runtime')
46
+ expect(html).not.toMatch(/<button[^>]*>remote-runtime<\/button>/)
47
+ expect(html).not.toContain('Observed submitter Job')
48
+ })
49
+ it('explains selected-cluster cleanup and gate-dependent policies', () => {
50
+ expect(render({ ...root, spec: { clusterSelector: { 'ray.io/cluster': 'shared' }, shutdownAfterJobFinishes: true } })).toContain('cleanup is not applied')
51
+ const html = render({ ...root, spec: { deletionStrategy: { deletionRules: [{ policy: 'DeleteWorkers', condition: { jobStatus: 'SUCCEEDED', ttlSeconds: 0 } }] } } })
52
+ expect(html).toContain('RayJobDeletionPolicy')
53
+ expect(html).toContain('DeleteWorkers')
54
+ expect(html).toContain('TTL 0s')
55
+ expect(html).not.toContain('No completion shutdown requested')
56
+ expect(render({ ...root, spec: { clusterSelector: { bad: 'selector' } } })).toContain('cleanup is not applied')
57
+ })
58
+ })
59
+
60
+ describe('controller message disclosure', () => {
61
+ it('keeps short messages inline', () => {
62
+ expect(render({ ...root, status: { message: 'Submission failed.' } })).not.toContain('Full controller message')
63
+ })
64
+ it.each(['x'.repeat(20000), 'Failure\nline two\nline three\nline four\nTraceback: final cause'])('retains the entire long message behind a collapsed disclosure', message => {
65
+ const html = render({ ...root, status: { message } })
66
+ expect(html).toContain('Full controller message')
67
+ expect(html).toContain('aria-expanded="false"')
68
+ expect(html).toContain(message)
69
+ expect(html.split('Full controller message')[0]).toContain(message.trim().split('\n').at(-1)!.slice(-240))
70
+ })
71
+ })
@@ -0,0 +1,134 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { ResourceRef } from '../../../types'
3
+ import { formatRelativeAgeTime } from '../../../utils/format'
4
+ import { Tooltip } from '../../ui/Tooltip'
5
+ import { Badge } from '../../ui/Badge'
6
+ import { Disclosure } from '../../ui/Disclosure'
7
+ import { AlertBanner, Property, PropertyList, ResourceLink } from '../../ui/drawer-components'
8
+ import { rayJobIsLocal, rayJobSubmissionMode } from '../resource-utils-ray'
9
+
10
+ export interface RayJobRendererProps {
11
+ data: any
12
+ onNavigate?: (ref: ResourceRef) => void
13
+ submitterEvidence?: ReactNode
14
+ selectedClusterEvidence?: ReactNode
15
+ admissionContent?: ReactNode
16
+ }
17
+
18
+ const card = 'min-w-0 overflow-hidden rounded-lg border border-theme-border bg-theme-surface'
19
+ const header = 'border-b border-theme-border bg-theme-elevated px-3 py-2.5 text-xs font-medium uppercase tracking-wider text-theme-text-secondary'
20
+
21
+ export function RayJobRenderer({ data, onNavigate, submitterEvidence, selectedClusterEvidence, admissionContent }: RayJobRendererProps) {
22
+ const spec = data.spec ?? {}
23
+ const status = data.status ?? {}
24
+ const local = rayJobIsLocal(data)
25
+ const mode = rayJobSubmissionMode(data)
26
+ const selectsExisting = Object.keys(spec.clusterSelector ?? {}).length > 0
27
+ const selected = spec.clusterSelector?.['ray.io/cluster']
28
+ const cluster = selected || status.rayClusterName
29
+ const lifecycle = status.jobDeploymentStatus
30
+ const jobStatus = status.jobStatus
31
+ const failed = lifecycle === 'Failed' || lifecycle === 'ValidationFailed'
32
+ const transitioning = ['Retrying', 'Suspending', 'Suspended'].includes(lifecycle)
33
+ const rules: any[] = spec.deletionStrategy?.deletionRules ?? []
34
+ const time = (value: string) => <Tooltip content={value}><time dateTime={value}>{formatRelativeAgeTime(value)}</time></Tooltip>
35
+ return <div className="@container/rayjob space-y-4">
36
+ {!local && <AlertBanner variant="info" title="Externally managed RayJob" message={`Managed by ${spec.managedBy}. Reported runtime names may refer to another cluster; local absence does not establish execution state.`} />}
37
+ {admissionContent}
38
+ <div className="grid items-start gap-4 @min-[1024px]/rayjob:grid-cols-[minmax(0,1.7fr)_minmax(320px,0.9fr)]">
39
+ <section className={card} aria-label="RayJob execution">
40
+ <h3 className={header}>Execution</h3>
41
+ <div className="space-y-3 p-3 text-sm">
42
+ <div className="flex flex-wrap gap-x-6 gap-y-3">
43
+ <div><p className="mb-1 text-xs text-theme-text-secondary">Controller lifecycle</p><Badge severity={failed ? 'error' : transitioning && lifecycle !== 'Suspended' ? 'warning' : 'neutral'}>{lifecycle || 'Not reported'}</Badge></div>
44
+ <div><p className="mb-1 text-xs text-theme-text-secondary">Last reported application state</p><Badge severity={failed || transitioning ? 'neutral' : jobStatus === 'FAILED' ? 'error' : jobStatus === 'RUNNING' || jobStatus === 'SUCCEEDED' ? 'success' : 'neutral'}>{jobStatus || 'Not reported'}</Badge></div>
45
+ </div>
46
+ {status.reason && <p className="break-words font-medium">{status.reason}</p>}
47
+ {status.message && <ControllerMessage message={status.message} />}
48
+ {failed && jobStatus && !['FAILED', 'STOPPED'].includes(jobStatus) && <p className="text-xs text-theme-text-secondary">{jobStatus === 'SUCCEEDED' ? 'The application reported success, but the controller lifecycle failed. Review the controller message and submitter evidence.' : 'Controller failure does not establish that the application has stopped.'}</p>}
49
+ {lifecycle === 'Retrying' && <p className="text-xs text-theme-text-secondary">Preparing to retry. The reported failure and submitter may belong to the attempt being cleaned up.</p>}
50
+ <PropertyList>
51
+ <Property label="Suspension requested" value={spec.suspend === true ? 'Yes' : 'No'} />
52
+ {status.jobId && <Property label="Ray job ID" value={<span className="break-all font-mono">{status.jobId}</span>} />}
53
+ {status.rayJobInfo?.startTime && <Property label="Application started" value={time(status.rayJobInfo.startTime)} />}
54
+ {status.rayJobInfo?.endTime && <Property label="Application ended" value={time(status.rayJobInfo.endTime)} />}
55
+ {status.failed != null && <Property label="Reported failed runs" value={status.failed} />}
56
+ {status.succeeded != null && <Property label="Reported successful runs" value={status.succeeded} />}
57
+ </PropertyList>
58
+ {status.jobStatusCheckFailureStartTime && <AlertBanner variant="warning" title="Application status checks are failing" >{'Checks have been failing since '}{time(status.jobStatusCheckFailureStartTime)}. Application state is the last reported observation.</AlertBanner>}
59
+ </div>
60
+ <div className="border-t border-theme-border p-3 text-xs text-theme-text-secondary">
61
+ <Disclosure summary="Timing and observation details">
62
+ <div className="space-y-2 pt-2">
63
+ {status.startTime && <p>Controller attempt started: {time(status.startTime)}</p>}
64
+ {status.endTime && <p>Controller terminal time: {time(status.endTime)}</p>}
65
+ <p>Application and controller times describe different events. Status is a controller snapshot; cleared fields are not retained as attempt history. See Timeline for recorded events and YAML for the full resource.</p>
66
+ </div>
67
+ </Disclosure>
68
+ </div>
69
+ </section>
70
+ <section className={card} aria-label="RayJob submission and runtime">
71
+ <h3 className={header}>Submission and runtime</h3>
72
+ <div className="space-y-3 p-3 text-sm">
73
+ <p className="font-medium break-words">{mode}</p>
74
+ <p className="text-theme-text-secondary">{mode === 'K8sJobMode' ? 'A Kubernetes Job submits the application to Ray.' : mode === 'HTTPMode' ? 'The controller submits directly through the Ray API; there is no submitter Job.' : mode === 'InteractiveMode' ? 'The controller waits for spec.jobId to identify a manually submitted Ray job.' : mode === 'SidecarMode' ? 'A container in the head Pod submits the application; there is no submitter Job.' : 'Submission behavior is defined by the reported mode.'}</p>
75
+ {selectsExisting && !selected && <p className="text-theme-text-secondary">Cluster selection has no ray.io/cluster name.</p>}
76
+ {cluster ? <div className="space-y-1 border-t border-theme-border pt-3">
77
+ <p className="text-xs text-theme-text-secondary">{selected ? 'Selected existing RayCluster' : 'Reported RayCluster'}</p>
78
+ <p className="break-all font-medium">{local ? <ResourceLink kind="rayclusters" group="ray.io" namespace={data.metadata.namespace} name={cluster} onNavigate={onNavigate} /> : cluster}</p>
79
+ {local && <p className="text-xs text-theme-text-secondary">Open the RayCluster for head/worker Pods and their logs.</p>}
80
+ {selectedClusterEvidence}
81
+ {selected && status.rayClusterName && selected !== status.rayClusterName && <div className="pt-2"><p className="text-xs text-theme-text-secondary">Last reported runtime differs from the selected cluster</p>{local ? <ResourceLink kind="rayclusters" group="ray.io" namespace={data.metadata.namespace} name={status.rayClusterName} onNavigate={onNavigate} /> : <p>{status.rayClusterName}</p>}</div>}
82
+ </div> : <p className="text-theme-text-secondary">No runtime cluster reported.</p>}
83
+ {local && mode === 'K8sJobMode' && <div className="space-y-2 border-t border-theme-border pt-3"><p className="text-xs font-medium text-theme-text-secondary">Observed submitter Job</p>{submitterEvidence ?? <p className="text-theme-text-secondary">Submitter evidence is not available.</p>}</div>}
84
+ </div>
85
+ </section>
86
+ </div>
87
+ <section className={card} aria-label="RayJob configuration">
88
+ <h3 className={header}>Job configuration</h3>
89
+ <div className="space-y-3 p-3 text-sm">
90
+ <p className="text-xs text-theme-text-secondary">Entrypoint</p>
91
+ <pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-xs">{spec.entrypoint || 'Not specified'}</pre>
92
+ <div className="grid gap-4 @min-[1024px]/rayjob:grid-cols-2">
93
+ <PropertyList>
94
+ {spec.entrypointNumCpus != null && <Property label="Entrypoint CPUs" value={spec.entrypointNumCpus} />}
95
+ {spec.entrypointNumGpus != null && <Property label="Entrypoint GPUs" value={spec.entrypointNumGpus} />}
96
+ {spec.entrypointResources && <Property label="Entrypoint custom resources" value={<code className="break-all">{spec.entrypointResources}</code>} />}
97
+ {spec.backoffLimit != null && <Property label="RayJob retry limit" value={spec.backoffLimit} />}
98
+ {spec.submitterConfig?.backoffLimit != null && <Property label="Submitter retry limit" value={spec.submitterConfig.backoffLimit} />}
99
+ {spec.activeDeadlineSeconds != null && <Property label="Active deadline" value={`${spec.activeDeadlineSeconds}s`} />}
100
+ {spec.preRunningDeadlineSeconds != null && <Property label="Pre-running deadline" value={`${spec.preRunningDeadlineSeconds}s`} />}
101
+ </PropertyList>
102
+ <div className="space-y-2 text-theme-text-secondary">
103
+ <p className="font-medium text-theme-text-primary">Runtime cleanup</p>
104
+ {selectsExisting ? <p>Runtime cleanup is not applied to a selected existing cluster.</p> : <>
105
+ {!spec.deletionStrategy && <p>{spec.shutdownAfterJobFinishes === true ? `Cluster shutdown requested after completion${spec.ttlSecondsAfterFinished != null ? ` (TTL ${spec.ttlSecondsAfterFinished}s)` : ''}.` : 'No completion shutdown requested.'}</p>}
106
+ {spec.deletionStrategy && <Disclosure summary={`Deletion policy${rules.length ? ` · ${rules.length} ${rules.length === 1 ? 'rule' : 'rules'}` : ''}`}>
107
+ <div className="space-y-2 pt-2">
108
+ <p>Requires the operator’s RayJobDeletionPolicy feature gate. These are declared rules, not proof that cleanup occurred.</p>
109
+ {rules.map((rule, index) => <p className="break-words" key={index}>{rule.policy} · {rule.condition?.jobStatus ? `Application ${rule.condition.jobStatus}` : `Controller ${rule.condition?.jobDeploymentStatus}`} · TTL {rule.condition?.ttlSeconds ?? 0}s</p>)}
110
+ {spec.deletionStrategy.onSuccess && <p>On success: {spec.deletionStrategy.onSuccess.policy}</p>}
111
+ {spec.deletionStrategy.onFailure && <p>On failure: {spec.deletionStrategy.onFailure.policy}</p>}
112
+ </div>
113
+ </Disclosure>}
114
+ </>}
115
+ <p className="text-xs">Operator configuration can affect cleanup. Missing runtime resources do not establish application failure.</p>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </section>
120
+ </div>
121
+ }
122
+
123
+ function ControllerMessage({ message }: { message: string }) {
124
+ const text = message.trim()
125
+ const lines = text.split('\n')
126
+ const isLong = text.length > 480 || lines.length > 4
127
+ if (!isLong) return <p className="max-w-3xl whitespace-pre-wrap break-words">{message}</p>
128
+ return <div className="min-w-0 space-y-2">
129
+ <p className="max-w-3xl whitespace-pre-wrap break-words">{lines.slice(0, 2).join('\n').slice(0, 240)}{'\n…\n'}{text.slice(-240).split('\n').slice(-1)[0]}</p>
130
+ <Disclosure summary="Full controller message" summaryClassName="text-xs text-accent-text">
131
+ <pre tabIndex={0} className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap break-words rounded border border-theme-border bg-theme-base p-3 font-mono text-xs">{message}</pre>
132
+ </Disclosure>
133
+ </div>
134
+ }
@@ -225,13 +225,14 @@ describe("Reflector resource hierarchy", () => {
225
225
  it("keeps ConfigMap data ahead of detailed settings and source mirror list", () => {
226
226
  const html = renderToStaticMarkup(
227
227
  <ResourceRendererDispatch
228
- kind="configmaps"
229
228
  data={{
230
229
  ...resource({ "reflection-allowed": "true" }),
231
230
  data: { setting: "value" },
232
231
  }}
233
232
  resource={{ kind: "configmaps", namespace: "app", name: "mirror" }}
234
233
  relationships={{ reflection: { mirrors: [mirror] } }}
234
+ onCopy={() => {}}
235
+ copied={null}
235
236
  />,
236
237
  );
237
238
  expect(html.indexOf("Data (1 keys)")).toBeLessThan(
@@ -242,7 +243,6 @@ describe("Reflector resource hierarchy", () => {
242
243
  it("puts TLS expiry and mirror provenance ahead of data, with details below", () => {
243
244
  const html = renderToStaticMarkup(
244
245
  <ResourceRendererDispatch
245
- kind="secrets"
246
246
  data={{
247
247
  ...resource({ reflects: "source/settings" }),
248
248
  type: "kubernetes.io/tls",
@@ -268,6 +268,8 @@ describe("Reflector resource hierarchy", () => {
268
268
  ],
269
269
  } as any
270
270
  }
271
+ onCopy={() => {}}
272
+ copied={null}
271
273
  />,
272
274
  );
273
275
  expect(html.indexOf("Certificate has expired")).toBeLessThan(
@@ -371,7 +371,7 @@ describe('rolloutActions promote reachability', () => {
371
371
 
372
372
  it('blocks promote, promote-full and skip-step alike on an aborted rollout', () => {
373
373
  const data = canaryRollout({ phase: 'Degraded', abort: true, currentStepIndex: 1 })
374
- for (const action of ['promote', 'promote-full', 'skip-step']) {
374
+ for (const action of ['promote', 'promote-full', 'skip-step'] as const) {
375
375
  expect(blockedReason(data, action)).toBe('Retry the rollout first')
376
376
  }
377
377
  })
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, useId } from 'react'
2
2
  import { Server, ExternalLink, Scale, Minus, Plus, Shield } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { PolicySection } from './PolicySection'
@@ -131,6 +131,7 @@ function compactHPASummary(diagnosis: HPADiagnosis): string {
131
131
  }
132
132
 
133
133
  export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale, isScalePending, scaleBlockedBy, scalerDiagnostics, workloadPods, onRequestRefresh, rbacData, rbacLoading, rbacError, policyData, policyLoading, policyError, namespaceLimitRangeNames }: WorkloadRendererProps) {
134
+ const titleId = useId()
134
135
  const status = data.status || {}
135
136
  const spec = data.spec || {}
136
137
  const metadata = data.metadata || {}
@@ -288,13 +289,14 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
288
289
  </Section>
289
290
 
290
291
  {/* Scale Dialog */}
291
- <DialogPortal open={showScaleDialog} onClose={() => setShowScaleDialog(false)} className="w-80 p-4">
292
- <h3 className="text-sm font-medium text-theme-text-primary mb-4">
292
+ <DialogPortal ariaLabelledBy={titleId} open={showScaleDialog} onClose={() => setShowScaleDialog(false)} className="w-80 p-4">
293
+ <h3 id={titleId} className="text-sm font-medium text-theme-text-primary mb-4">
293
294
  Scale {metadata.name}
294
295
  </h3>
295
296
 
296
297
  <div className="flex items-center justify-center gap-4 mb-4">
297
298
  <button
299
+ aria-label="Decrease replicas"
298
300
  onClick={() => setTargetReplicas(Math.max(0, targetReplicas - 1))}
299
301
  className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
300
302
  disabled={targetReplicas <= 0}
@@ -307,12 +309,14 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
307
309
  min="0"
308
310
  max="10000"
309
311
  value={targetReplicas}
312
+ aria-label="Replicas"
310
313
  onChange={(e) => setTargetReplicas(Math.min(10000, Math.max(0, parseInt(e.target.value) || 0)))}
311
314
  className="w-20 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-blue-500"
312
315
  autoFocus
313
316
  />
314
317
 
315
318
  <button
319
+ aria-label="Increase replicas"
316
320
  onClick={() => setTargetReplicas(Math.min(10000, targetReplicas + 1))}
317
321
  disabled={targetReplicas >= 10000}
318
322
  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"
@@ -190,3 +190,5 @@ export { AdmissionCheckRenderer, ProvisioningRequestRenderer } from './KueueProv
190
190
  export { RayServiceRenderer } from './RayServiceRenderer'
191
191
 
192
192
  export { RayClusterRenderer } from './RayClusterRenderer'
193
+
194
+ export { RayJobRenderer } from './RayJobRenderer'
@@ -1,5 +1,6 @@
1
1
  // Kueue + Cluster Autoscaler ProvisioningRequest CRD utility functions
2
2
 
3
+ import type { BadgeSeverity } from '../ui/Badge'
3
4
  import type { StatusBadge } from './resource-utils'
4
5
  import { healthColors } from './resource-utils'
5
6
 
@@ -293,3 +294,16 @@ export function getProvisioningRequestClassName(resource: any): string {
293
294
  export function getProvisioningRequestPodSetCount(resource: any): number {
294
295
  return (resource?.spec?.podSets || []).length
295
296
  }
297
+
298
+ export function admissionCheckSeverity(state?: string): BadgeSeverity {
299
+ switch (state) {
300
+ case 'Ready':
301
+ return 'success'
302
+ case 'Rejected':
303
+ return 'error'
304
+ case 'Retry':
305
+ return 'warning'
306
+ default:
307
+ return 'neutral'
308
+ }
309
+ }
@@ -63,6 +63,12 @@ export function getRayJobStatus(resource: any): StatusBadge {
63
63
  return { text: deploymentStatus, color: healthColors.unhealthy, level: 'unhealthy' }
64
64
  }
65
65
 
66
+ if (['Retrying', 'Suspending', 'Suspended'].includes(deploymentStatus)) {
67
+ return deploymentStatus === 'Suspended'
68
+ ? { text: deploymentStatus, color: healthColors.neutral, level: 'neutral' }
69
+ : { text: deploymentStatus, color: healthColors.degraded, level: 'degraded' }
70
+ }
71
+
66
72
  switch (jobStatus) {
67
73
  case 'SUCCEEDED':
68
74
  return { text: 'Succeeded', color: healthColors.neutral, level: 'neutral' }
@@ -77,15 +83,11 @@ export function getRayJobStatus(resource: any): StatusBadge {
77
83
  }
78
84
 
79
85
  switch (deploymentStatus) {
80
- case 'Suspended':
81
- return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
82
86
  case 'Complete':
83
87
  return { text: 'Complete', color: healthColors.neutral, level: 'neutral' }
84
88
  case 'Running':
85
89
  return { text: 'Running', color: healthColors.healthy, level: 'healthy' }
86
90
  case 'Initializing':
87
- case 'Suspending':
88
- case 'Retrying':
89
91
  case 'Waiting':
90
92
  return { text: deploymentStatus, color: healthColors.degraded, level: 'degraded' }
91
93
  }
@@ -184,3 +186,11 @@ export function getRayCronJobLastSchedule(resource: any): string {
184
186
  if (!lastSchedule) return '-'
185
187
  return formatAge(lastSchedule)
186
188
  }
189
+
190
+ export function rayJobIsLocal(resource: any): boolean {
191
+ return !resource.spec?.managedBy || resource.spec.managedBy === 'ray.io/kuberay-operator'
192
+ }
193
+
194
+ export function rayJobSubmissionMode(resource: any): string {
195
+ return resource.spec?.submissionMode || 'K8sJobMode'
196
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState, type DragEvent as ReactDragEvent } from 'react'
1
+ import { useCallback, useEffect, useRef, useState, type DragEvent as ReactDragEvent, useId } from 'react'
2
2
  import { AlertTriangle, Check, Loader2, Upload, X } from 'lucide-react'
3
3
  import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
4
4
  import { ConfirmDialog } from '../ui/ConfirmDialog'
@@ -69,6 +69,7 @@ export function CreateResourceDialog({
69
69
  schemaLoader,
70
70
  onCreated,
71
71
  }: CreateResourceDialogProps) {
72
+ const titleId = useId()
72
73
  const [yaml, setYaml] = useState(initialYaml)
73
74
  const [mode, setMode] = useState<'apply' | 'create'>('apply')
74
75
  const [dryRun, setDryRun] = useState(false)
@@ -363,6 +364,7 @@ export function CreateResourceDialog({
363
364
 
364
365
  return (
365
366
  <DialogPortal
367
+ ariaLabelledBy={titleId}
366
368
  open={open}
367
369
  onClose={handleClose}
368
370
  closable={!pending}
@@ -374,6 +376,7 @@ export function CreateResourceDialog({
374
376
  >
375
377
  {preview ? (
376
378
  <YamlReview
379
+ titleId={titleId}
377
380
  submittedYaml={preview.yaml}
378
381
  documents={preview.documents}
379
382
  nonAtomic={preview.nonAtomic}
@@ -393,11 +396,12 @@ export function CreateResourceDialog({
393
396
  ) : (
394
397
  <>
395
398
  <div className="flex shrink-0 items-center justify-between border-b border-theme-border px-5 py-3.5">
396
- <h2 className="text-sm font-semibold text-theme-text-primary">{dialogTitle}</h2>
399
+ <h2 id={titleId} className="text-sm font-semibold text-theme-text-primary">{dialogTitle}</h2>
397
400
  <Tooltip content="Close">
398
401
  <button
399
402
  type="button"
400
403
  onClick={handleClose}
404
+ aria-label="Close"
401
405
  className="rounded p-1 text-theme-text-secondary transition-colors hover:bg-theme-hover"
402
406
  >
403
407
  <X className="h-4 w-4" />