@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
@@ -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" />
@@ -88,6 +88,16 @@ describe('DrainPlanContent', () => {
88
88
  )
89
89
  }
90
90
 
91
+ it('says a drain plan needs a newer Radar when the cluster predates it', () => {
92
+ const html = render({
93
+ planSupported: false,
94
+ planUpgrade: { feature: 'Drain plans', minimumVersion: 'v1.14.0', currentVersion: 'v1.13.1' },
95
+ })
96
+ expect(html).toContain('Drain plans')
97
+ expect(html).toContain('need Radar v1.14 or newer on this cluster.')
98
+ expect(html).not.toContain('Computing the plan')
99
+ })
100
+
91
101
  it('shows per-pod outcomes with reasons and calls the result an estimate', () => {
92
102
  const html = render({ plan: plan([pod('web', 'may-block', { pdb: 'shop/web', reason: 'PodDisruptionBudget shop/web currently allows no disruptions' }), pod('agent', 'skip')]) })
93
103
  expect(html).toContain('may block')
@@ -3,6 +3,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
3
3
  import { Badge, type BadgeSeverity } from '../ui/Badge'
4
4
  import { AlertBanner } from '../ui/drawer-components'
5
5
  import { Tooltip } from '../ui/Tooltip'
6
+ import { RadarUpgradeNote } from '../ui/RadarUpgradeNote'
7
+ import type { RadarUpgradeRequirement } from '../../types/fetch-error'
6
8
  import { pluralize } from '../../utils/pluralize'
7
9
 
8
10
  // Shapes returned by POST /api/nodes/{name}/drain-plan.
@@ -102,6 +104,8 @@ interface DrainPlanContentProps {
102
104
  options: DrainDialogOptions
103
105
  onOptionsChange: (options: DrainDialogOptions) => void
104
106
  planSupported: boolean
107
+ /** Why there is no plan, when the connected Radar predates drain plans. */
108
+ planUpgrade?: RadarUpgradeRequirement | null
105
109
  /** Recompute the plan with the current options; also the retry after a failed plan. */
106
110
  onRefreshPlan?: () => void
107
111
  }
@@ -112,7 +116,7 @@ interface DrainPlanContentProps {
112
116
  * react-dom/server); the enclosing dialog owns the confirm gating.
113
117
  */
114
118
  export function DrainPlanContent({
115
- nodeName, plan, loading, error, options, onOptionsChange, planSupported, onRefreshPlan,
119
+ nodeName, plan, loading, error, options, onOptionsChange, planSupported, planUpgrade, onRefreshPlan,
116
120
  }: DrainPlanContentProps) {
117
121
  const current = planMatches(plan, nodeName, options) ? plan : null
118
122
  const atRisk = current ? emptyDirPodsAtRisk(current) : []
@@ -140,6 +144,8 @@ export function DrainPlanContent({
140
144
  </label>
141
145
  </div>
142
146
 
147
+ {!planSupported && planUpgrade && <RadarUpgradeNote requirement={planUpgrade} />}
148
+
143
149
  {planSupported && loading && (
144
150
  <div className="flex items-center gap-2 text-theme-text-secondary">
145
151
  <Loader2 className="w-4 h-4 animate-spin" /> Computing the plan…
@@ -259,12 +265,14 @@ interface DrainPlanDialogProps {
259
265
  isDraining: boolean
260
266
  /** Whether the host can compute plans. Without it the dialog confirms without one. */
261
267
  planSupported: boolean
268
+ /** Why there is no plan, when the connected Radar predates drain plans. */
269
+ planUpgrade?: RadarUpgradeRequirement | null
262
270
  /** Recompute the plan with the current options; also the retry after a failed plan. */
263
271
  onRefreshPlan?: () => void
264
272
  }
265
273
 
266
274
  export function DrainPlanDialog({
267
- open, nodeName, plan, loading, error, options, onOptionsChange, onConfirm, onClose, isDraining, planSupported, onRefreshPlan,
275
+ open, nodeName, plan, loading, error, options, onOptionsChange, onConfirm, onClose, isDraining, planSupported, planUpgrade, onRefreshPlan,
268
276
  }: DrainPlanDialogProps) {
269
277
  const confirmEnabled = canConfirmDrain({ plan, nodeName, options, loading, error, planSupported })
270
278
 
@@ -274,7 +282,9 @@ export function DrainPlanDialog({
274
282
  onClose={onClose}
275
283
  onConfirm={() => onConfirm(options)}
276
284
  title="Drain Node"
277
- message={`Cordon "${nodeName}" and evict the pods listed below. The list is an estimate: the drain re-lists live state when it runs.`}
285
+ message={planSupported
286
+ ? `Cordon "${nodeName}" and evict the pods listed below. The list is an estimate: the drain re-lists live state when it runs.`
287
+ : `Cordon "${nodeName}" and evict its pods.`}
278
288
  confirmLabel={isDraining ? 'Draining...' : 'Drain'}
279
289
  variant="danger"
280
290
  isLoading={isDraining}
@@ -290,6 +300,7 @@ export function DrainPlanDialog({
290
300
  options={options}
291
301
  onOptionsChange={onOptionsChange}
292
302
  planSupported={planSupported}
303
+ planUpgrade={planUpgrade}
293
304
  onRefreshPlan={onRefreshPlan}
294
305
  />
295
306
  </ConfirmDialog>
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, useRef } from 'react'
1
+ import { useEffect, useState, useRef, useId } from 'react'
2
2
  import {
3
3
  RefreshCw,
4
4
  Terminal,
@@ -25,6 +25,7 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
25
25
  import { AlertBanner } from '../ui/drawer-components'
26
26
  import { DialogPortal } from '../ui/DialogPortal'
27
27
  import type { SelectedResource, WorkloadRevision } from '../../types'
28
+ import type { RadarUpgradeRequirement } from '../../types/fetch-error'
28
29
  import { displayKindName } from '../ui/drawer-components'
29
30
  import { getDefaultContainerName } from '../resources/resource-utils'
30
31
  import { SetImageDialog, type ManagedImageSource } from './SetImageDialog'
@@ -143,10 +144,12 @@ interface ResourceActionsBarProps {
143
144
  drainPlan?: DrainPlan | null
144
145
  isPlanningDrain?: boolean
145
146
  drainPlanError?: string | null
146
- // The connected backend has no drain-plan endpoint (version skew, e.g. a newer
147
- // frontend against an older radar). The dialog falls back to the plan-less
148
- // acknowledgement-only mode instead of keeping Drain disabled on a dead request.
149
- drainPlanUnsupported?: boolean
147
+ // The connected Radar predates the drain-plan endpoint (a newer frontend
148
+ // against an older Radar). The dialog falls back to the plan-less
149
+ // acknowledgement-only mode instead of keeping Drain disabled on a dead
150
+ // request, and names the Radar version that serves plans so the user knows a
151
+ // preview exists.
152
+ drainPlanUpgrade?: RadarUpgradeRequirement | null
150
153
  }
151
154
 
152
155
  export function ResourceActionsBar({
@@ -177,7 +180,7 @@ export function ResourceActionsBar({
177
180
  onCordonNode, isCordoningNode,
178
181
  onUncordonNode, isUncordoningNode,
179
182
  onDrainNode, isDrainingNode,
180
- onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUnsupported,
183
+ onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUpgrade,
181
184
  }: ResourceActionsBarProps) {
182
185
  const kind = resource.kind.toLowerCase()
183
186
  const coreBatchJob = isCoreBatchJob(kind, resource.group)
@@ -202,13 +205,14 @@ export function ResourceActionsBar({
202
205
  // open: the host reports it through a mutation error, which the next request clears,
203
206
  // so without latching every option change would refire a request known to 404 and
204
207
  // bounce the dialog out of its fallback mode.
205
- const [planUnsupported, setPlanUnsupported] = useState(false)
208
+ const [planUpgrade, setPlanUpgrade] = useState<RadarUpgradeRequirement | null>(null)
206
209
  useEffect(() => {
207
- if (drainPlanUnsupported) setPlanUnsupported(true)
208
- }, [drainPlanUnsupported])
210
+ if (drainPlanUpgrade) setPlanUpgrade(drainPlanUpgrade)
211
+ }, [drainPlanUpgrade])
209
212
  useEffect(() => {
210
- if (!showDrainConfirm) setPlanUnsupported(false)
213
+ if (!showDrainConfirm) setPlanUpgrade(null)
211
214
  }, [showDrainConfirm])
215
+ const planUnsupported = planUpgrade !== null
212
216
  const planSupported = Boolean(onPlanDrain) && !planUnsupported
213
217
 
214
218
  // Fetch (and refetch on option changes) the read-only plan while the drain dialog is open.
@@ -719,6 +723,7 @@ export function ResourceActionsBar({
719
723
  options={drainOptions}
720
724
  onOptionsChange={setDrainOptions}
721
725
  planSupported={planSupported}
726
+ planUpgrade={planUpgrade}
722
727
  onRefreshPlan={planSupported ? () => onPlanDrain?.({ name: resource.name, options: drainOptions }) : undefined}
723
728
  isDraining={Boolean(isDrainingNode)}
724
729
  onClose={() => {
@@ -1053,6 +1058,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
1053
1058
  isRollingBack?: boolean
1054
1059
  onRolloutPromoteFull?: (params: { namespace: string; name: string }) => void | Promise<unknown>
1055
1060
  }) {
1061
+ const titleId = useId()
1056
1062
  const [confirmRevision, setConfirmRevision] = useState<number | null>(null)
1057
1063
  const [diffRevision, setDiffRevision] = useState<number | null>(null)
1058
1064
  const [promoteAfterRollback, setPromoteAfterRollback] = useState(false)
@@ -1144,6 +1150,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
1144
1150
 
1145
1151
  return (
1146
1152
  <DialogPortal
1153
+ ariaLabelledBy={titleId}
1147
1154
  open={open}
1148
1155
  onClose={handleClose}
1149
1156
  closable={!busy}
@@ -1152,7 +1159,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
1152
1159
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
1153
1160
  <div className="flex items-center gap-2">
1154
1161
  <History className="w-5 h-5 text-amber-500" />
1155
- <h3 className="text-lg font-semibold text-theme-text-primary">Revision History</h3>
1162
+ <h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">Revision History</h3>
1156
1163
  {diffRevision && currentRevision && (
1157
1164
  <span className="badge ml-2 bg-blue-500/15 text-blue-400">
1158
1165
  <GitCompare className="w-3 h-3" />
@@ -315,11 +315,9 @@ describe('getResourceStatus — colliding plurals', () => {
315
315
  // fine; being scored with Argo's vocabulary is not, because that attaches a
316
316
  // HealthLevel derived from a phase Katib never reports.
317
317
  it('does not score a Katib Experiment with the Argo vocabulary', () => {
318
- const katib = getResourceStatus('experiments', {
319
- apiVersion: 'kubeflow.org/v1beta1',
320
- status: { conditions: [{ type: 'Running', status: 'True' }] },
321
- })
322
- expect(katib?.level).toBeUndefined()
318
+ const katib = { apiVersion: 'kubeflow.org/v1beta1', status: { conditions: [{ type: 'Running', status: 'True' }] } }
319
+ // Read the way any unrelated CRD with the same status would be.
320
+ expect(getResourceStatus('experiments', katib)).toEqual(getResourceStatus('trainingruns', katib))
323
321
  })
324
322
 
325
323
  it('fabricates no engine status for a third-party backups CRD', () => {
@@ -1080,3 +1078,12 @@ it('uses native RayCluster detail only for ray.io/v1', () => {
1080
1078
  if (apiVersion !== 'ray.io/v1') expect(html).toContain('2.55.0')
1081
1079
  }
1082
1080
  })
1081
+
1082
+ it('uses RayJob native detail only for the supported exact API', () => {
1083
+ for (const apiVersion of ['ray.io/v1', 'foreign.io/v1', 'ray.io/v99', 'ray.io/v1alpha1']) {
1084
+ const data = { apiVersion, kind: 'RayJob', metadata: { name: 'train', namespace: 'ml' }, spec: { customField: 'native-fallback' } }
1085
+ const html = renderKind('rayjobs', data, 'ml')
1086
+ expect(html.includes('RayJob execution')).toBe(apiVersion === 'ray.io/v1')
1087
+ if (apiVersion !== 'ray.io/v1') { expect(html).toContain('native-fallback'); expect(getResourceStatus('rayjobs', data)).toBeNull() }
1088
+ }
1089
+ })