@skyhook-io/radar-app 1.14.6 → 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 (50) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +51 -6
  3. package/src/api/client.admission.test.ts +34 -0
  4. package/src/api/client.ts +37 -3
  5. package/src/api/usage-data.fixtures.ts +33 -0
  6. package/src/api/usage-data.ts +165 -0
  7. package/src/components/CloudFunnelButton.test.tsx +2 -2
  8. package/src/components/CloudFunnelButton.tsx +99 -55
  9. package/src/components/SelfManagedStart.tsx +96 -0
  10. package/src/components/cloudConnectHandoff.test.ts +1 -1
  11. package/src/components/cloudConnectHandoff.ts +1 -1
  12. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  13. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  14. package/src/components/dialog-names.test.ts +69 -0
  15. package/src/components/execution/BatchExecutionView.tsx +1 -1
  16. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  17. package/src/components/execution/JobSetAdmission.tsx +16 -7
  18. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  19. package/src/components/home/HomeView.tsx +6 -2
  20. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  21. package/src/components/home/RadarVersionLine.tsx +28 -3
  22. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  23. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  24. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  25. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  26. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  27. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  28. package/src/components/settings/PrivacySection.test.tsx +84 -0
  29. package/src/components/settings/PrivacySection.tsx +140 -0
  30. package/src/components/settings/SettingsDialog.tsx +13 -52
  31. package/src/components/settings/controls.tsx +55 -0
  32. package/src/components/settings/settings-state.ts +1 -0
  33. package/src/components/ui/ErrorBoundary.tsx +5 -0
  34. package/src/components/ui/Omnibar.tsx +16 -0
  35. package/src/components/ui/UpdateNotification.tsx +13 -9
  36. package/src/components/ui/command-items.ts +14 -0
  37. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  38. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  39. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  40. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  41. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  42. package/src/components/whats-new/WhatsNew.tsx +423 -0
  43. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  44. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  45. package/src/components/whats-new/releaseNotes.ts +121 -0
  46. package/src/components/workload/WorkloadView.tsx +11 -1
  47. package/src/k8s-ui-exports.test.ts +73 -0
  48. package/src/utils/navigation.test.ts +17 -1
  49. package/src/utils/navigation.ts +16 -0
  50. package/src/utils/version.ts +11 -0
@@ -1,42 +1,165 @@
1
- import { renderToStaticMarkup } from 'react-dom/server'
2
- import { beforeEach, describe, expect, it, vi } from 'vitest'
3
- import type { KueueAdmissionResponse } from '@skyhook-io/k8s-ui/types/scheduling'
4
- import { JobSetAdmission } from './JobSetAdmission'
5
-
6
- const state = vi.hoisted(() => ({ data: undefined as KueueAdmissionResponse | undefined, query: vi.fn() }))
7
- vi.mock('../../api/client', () => ({
8
- ApiError: class extends Error {},
9
- useKueueAdmission: (...args: unknown[]) => {
10
- state.query(...args)
11
- return { data: state.data, isLoading: false, refetch: vi.fn() }
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { KueueAdmission } from "./JobSetAdmission";
4
+ const mock = vi.hoisted(() => ({ result: {} as any, calls: [] as any[] }));
5
+ vi.mock("../../api/client", () => ({
6
+ ApiError: class extends Error {
7
+ status = 403;
8
+ },
9
+ useKueueAdmission: (...args: any[]) => {
10
+ mock.calls.push(args);
11
+ return mock.result;
12
+ },
13
+ }));
14
+ const job = {
15
+ apiVersion: "batch/v1",
16
+ kind: "Job",
17
+ metadata: { name: "train", namespace: "ml", uid: "current" },
18
+ spec: { suspend: true },
19
+ };
20
+ const empty = {
21
+ uid: "current",
22
+ installed: true,
23
+ workloads: [],
24
+ total: 0,
25
+ truncated: false,
26
+ };
27
+ const evidence = {
28
+ ...empty,
29
+ total: 1,
30
+ workloads: [
31
+ {
32
+ name: "owned-workload",
33
+ uid: "w",
34
+ apiVersion: "kueue.x-k8s.io/v1beta2",
35
+ projection: "unsupported",
36
+ },
37
+ ],
38
+ };
39
+ const hinted = {
40
+ ...job,
41
+ metadata: {
42
+ ...job.metadata,
43
+ labels: { "kueue.x-k8s.io/queue-name": "ready" },
12
44
  },
13
- }))
45
+ };
46
+ const render = (resource: any = job) =>
47
+ renderToStaticMarkup(
48
+ <KueueAdmission resource={resource} namespace="ml" name="train" />,
49
+ );
50
+ describe("Job admission host", () => {
51
+ beforeEach(() => {
52
+ mock.result = {};
53
+ mock.calls = [];
54
+ });
55
+ it.each([
56
+ { isLoading: true },
57
+ { data: empty },
58
+ { error: new Error("denied") },
59
+ ])("keeps plain suspended Jobs quiet: %j", (result) => {
60
+ mock.result = result;
61
+ expect(render()).toBe("");
62
+ expect(mock.calls[0][3]).toEqual({
63
+ isJob: true,
64
+ isRayJob: false,
65
+ hinted: false,
66
+ terminal: false,
67
+ });
68
+ });
69
+ it("discovers unlabeled evidence but hides it after errors", () => {
70
+ mock.result = { data: evidence };
71
+ expect(render()).toContain("owned-workload");
72
+ mock.result.error = new Error("cache unavailable");
73
+ expect(render()).toContain("cache unavailable");
74
+ expect(render()).not.toContain("owned-workload");
75
+ });
76
+ it("rejects reused-name evidence", () => {
77
+ mock.result = { data: { ...evidence, uid: "old" } };
78
+ expect(render(hinted)).toContain("different workload instance");
79
+ expect(render(hinted)).not.toContain("owned-workload");
80
+ });
81
+ it("shows missing evidence and conditional owner guidance for a hinted child", () => {
82
+ mock.result = { data: empty };
83
+ const html = render({
84
+ ...hinted,
85
+ metadata: {
86
+ ...hinted.metadata,
87
+ ownerReferences: [{ controller: true, kind: "JobSet", name: "parent" }],
88
+ },
89
+ });
90
+ expect(html).toContain("Admission may be tracked on the owning resource");
91
+ expect(html).not.toContain("Admission is tracked");
92
+ });
93
+ it("does not use failed Pod counts as terminal or arbitrary managedBy as Kueue hint", () => {
94
+ render({
95
+ ...job,
96
+ spec: { managedBy: "other.io/controller" },
97
+ status: { failed: 1 },
98
+ });
99
+ expect(mock.calls[0][3]).toEqual({
100
+ isJob: true,
101
+ isRayJob: false,
102
+ hinted: false,
103
+ terminal: false,
104
+ });
105
+ });
106
+ it("preserves JobSet errors", () => {
107
+ mock.result = { error: new Error("denied") };
108
+ expect(
109
+ render({
110
+ ...job,
111
+ apiVersion: "jobset.x-k8s.io/v1alpha2",
112
+ kind: "JobSet",
113
+ }),
114
+ ).toContain("denied");
115
+ });
116
+ });
14
117
 
15
- beforeEach(() => {
16
- state.query.mockClear()
17
- state.data = { uid: 'old-uid', installed: true, total: 1, truncated: false, workloads: [{
18
- apiVersion: 'kueue.x-k8s.io/v1beta2', namespace: 'ml', name: 'old-workload', uid: 'workload-uid', generation: 1,
19
- createdAt: null, deleting: false, projection: 'unsupported', linksLimited: false,
20
- }] }
21
- })
118
+ describe("JobSet admission identity", () => {
119
+ it("rejects evidence and absence for a different root instance", () => {
120
+ const resource = {
121
+ ...job,
122
+ apiVersion: "jobset.x-k8s.io/v1alpha2",
123
+ kind: "JobSet",
124
+ };
125
+ mock.result = { data: evidence };
126
+ expect(render(resource)).toContain("owned-workload");
127
+ mock.result = { data: { ...evidence, uid: "old" } };
128
+ expect(render(resource)).toContain("different workload instance");
129
+ expect(render(resource)).not.toContain("owned-workload");
130
+ mock.result = { data: { ...empty, uid: "old", installed: false } };
131
+ expect(render(resource)).toContain("different workload instance");
132
+ mock.result.data.uid = "current";
133
+ expect(render(resource)).toBe("");
134
+ });
135
+ });
22
136
 
23
- function render(uid: string) {
24
- return renderToStaticMarkup(<JobSetAdmission resource={{ metadata: { uid } }} namespace="ml" name="training" />)
25
- }
137
+ it("does not suggest parent admission for a CronJob-created Job", () => {
138
+ mock.result = { data: empty };
139
+ const html = render({
140
+ ...hinted,
141
+ metadata: {
142
+ ...hinted.metadata,
143
+ ownerReferences: [
144
+ { apiVersion: "batch/v1", kind: "CronJob", controller: true },
145
+ ],
146
+ },
147
+ });
148
+ expect(html).toContain("No controller-owned Kueue Workload observed");
149
+ expect(html).not.toContain("Admission may be tracked");
150
+ });
26
151
 
27
- describe('JobSet admission identity', () => {
28
- it('rejects old evidence after recreation under the same name', () => {
29
- expect(render('old-uid')).toContain('old-workload')
30
- const html = render('new-uid')
31
- expect(state.query).toHaveBeenLastCalledWith('ml', 'training', 'new-uid')
32
- expect(html).not.toContain('old-workload')
33
- expect(html).toContain('different JobSet instance')
34
- })
35
152
 
36
- it('accepts absence only for the displayed root instance', () => {
37
- state.data = { uid: 'old-uid', installed: false, total: 0, truncated: false, workloads: [] }
38
- expect(render('new-uid')).toContain('different JobSet instance')
39
- state.data.uid = 'new-uid'
40
- expect(render('new-uid')).toBe('')
41
- })
42
- })
153
+ describe("RayJob admission", () => {
154
+ it("uses RayJob identity and controller terminal status without inventing a Kueue hint", () => {
155
+ mock.result = { data: empty };
156
+ expect(render({ ...job, apiVersion: "ray.io/v1", kind: "RayJob", spec: { managedBy: "ray.io/kuberay-operator" }, status: { jobDeploymentStatus: "Complete" } })).toBe("");
157
+ expect(mock.calls.at(-1)?.[3]).toEqual({ isJob: false, isRayJob: true, hinted: false, terminal: true });
158
+ });
159
+ it("keeps MultiKueue admission visible separately from remote execution", () => {
160
+ mock.result = { data: empty };
161
+ const html = render({ ...job, apiVersion: "ray.io/v1", kind: "RayJob", spec: { managedBy: "kueue.x-k8s.io/multikueue" } });
162
+ expect(html).toContain("No controller-owned Kueue Workload observed");
163
+ expect(mock.calls.at(-1)?.[3]).toEqual({ isJob: false, isRayJob: true, hinted: true, terminal: false });
164
+ });
165
+ });
@@ -1,17 +1,26 @@
1
1
  import { KueueAdmissionSection } from '@skyhook-io/k8s-ui/components/resources/KueueAdmissionSection'
2
2
  import { ApiError, useKueueAdmission } from '../../api/client'
3
3
 
4
- export function JobSetAdmission({ resource, namespace, name, onNavigate }: {
4
+ export function KueueAdmission({ resource, namespace, name, onNavigate, presentation }: {
5
+ presentation?: 'card' | 'drawer'
5
6
  resource: any
6
7
  namespace: string
7
8
  name: string
8
9
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
9
10
  }) {
10
- const query = useKueueAdmission(namespace, name, resource.metadata?.uid)
11
- const data = query.data?.uid === resource.metadata?.uid ? query.data : undefined
12
- const error = query.error?.message ?? (query.data && !data ? 'Admission evidence belongs to a different JobSet instance; waiting for a fresh observation.' : undefined)
11
+ const isJob = resource.apiVersion === 'batch/v1' && resource.kind === 'Job'
12
+ const isRayJob = resource.apiVersion === 'ray.io/v1' && resource.kind === 'RayJob'
13
13
  const labels = resource.metadata?.labels
14
- const externalExecution = Boolean(resource.spec?.managedBy && resource.spec.managedBy !== 'jobset.sigs.k8s.io/jobset-controller')
15
- const hinted = Boolean(labels?.['kueue.x-k8s.io/queue-name'] || labels?.['kueue.x-k8s.io/prebuilt-workload-name'] || externalExecution)
16
- return <KueueAdmissionSection data={data} loading={query.isLoading} error={error} forbidden={query.error instanceof ApiError && query.error.status === 403} hinted={hinted} externalExecution={externalExecution} onRetry={() => { void query.refetch() }} onNavigate={onNavigate} />
14
+ const annotations = resource.metadata?.annotations
15
+ const externalExecution = Boolean(resource.spec?.managedBy && resource.spec.managedBy !== (isRayJob ? 'ray.io/kuberay-operator' : isJob ? 'kubernetes.io/job-controller' : 'jobset.sigs.k8s.io/jobset-controller'))
16
+ const hinted = Boolean(labels?.['kueue.x-k8s.io/queue-name'] || labels?.['kueue.x-k8s.io/prebuilt-workload-name'] || annotations?.['kueue.x-k8s.io/prebuilt-workload-name'] || resource.spec?.managedBy === 'kueue.x-k8s.io/multikueue' || (!isJob && !isRayJob && externalExecution))
17
+ const terminal = (isRayJob && ['Complete', 'Failed', 'ValidationFailed'].includes(resource.status?.jobDeploymentStatus)) || (resource.status?.conditions ?? []).some((c: any) => ['Complete', 'Failed'].includes(c.type) && c.status === 'True')
18
+ const query = useKueueAdmission(namespace, name, resource.metadata?.uid, { isJob, isRayJob, hinted, terminal })
19
+ const data = query.data?.uid === resource.metadata?.uid ? query.data : undefined
20
+ const error = query.error?.message ?? (query.data && !data ? 'Admission evidence belongs to a different workload instance; waiting for a fresh observation.' : undefined)
21
+ if ((isJob || isRayJob) && !hinted && !data?.workloads.length) return null
22
+ const hasOwner = (resource.metadata?.ownerReferences ?? []).some((owner: any) => owner.controller === true && !(owner.apiVersion === 'batch/v1' && owner.kind === 'CronJob'))
23
+ return <KueueAdmissionSection presentation={presentation} data={data} loading={query.isLoading} error={error} forbidden={query.error instanceof ApiError && query.error.status === 403} hinted={hinted} externalExecution={externalExecution} hasOwner={isJob && hasOwner} onRetry={() => { void query.refetch() }} onNavigate={onNavigate} />
17
24
  }
25
+
26
+ export { KueueAdmission as JobSetAdmission }
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
1
+ import { useState, useId } from 'react'
2
2
  import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
3
3
  import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
@@ -50,6 +50,7 @@ function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError
50
50
  // GitOps) Radar can only track upgrades once the user declares where they live.
51
51
  // Registering a registry/org prefix lets Radar probe "<prefix>/<chartName>".
52
52
  export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue, sourceError }: TrackChartSourceDialogProps) {
53
+ const titleId = useId()
53
54
  const [value, setValue] = useState('')
54
55
  const { data: sources } = useHelmOCISources()
55
56
  const { data: clusterInfo } = useClusterInfo()
@@ -78,13 +79,13 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
78
79
  }
79
80
 
80
81
  return (
81
- <DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
82
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={onClose} className="max-w-lg w-full">
82
83
  <div className="flex items-start gap-3 p-4 border-b border-theme-border">
83
84
  <div className="flex items-center justify-center w-10 h-10 rounded-full shrink-0 bg-theme-hover">
84
85
  <Link2 className="w-5 h-5 text-theme-text-secondary" />
85
86
  </div>
86
87
  <div className="flex-1 min-w-0">
87
- <h3 className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
88
+ <h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
88
89
  <p className="text-sm text-theme-text-secondary mt-1">
89
90
  Helm doesn&apos;t record where a chart was installed from. Radar checks registered OCI
90
91
  prefixes for newer versions of your charts.
@@ -92,6 +93,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
92
93
  </div>
93
94
  <button
94
95
  onClick={onClose}
96
+ aria-label="Close"
95
97
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
96
98
  >
97
99
  <X className="w-5 h-5" />
@@ -52,9 +52,11 @@ interface HomeViewProps {
52
52
  onNavigateToUpgradeImpact?: () => void
53
53
  onNavigateToHelmRelease?: (namespace: string, release: string) => void
54
54
  onNavigateToManagerPath?: (path: string) => void
55
+ // Present only when release notes exist for this version.
56
+ onShowWhatsNew?: () => void
55
57
  }
56
58
 
57
- export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts, onNavigateToUpgradeImpact, onNavigateToHelmRelease, onNavigateToManagerPath }: HomeViewProps) {
59
+ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts, onNavigateToUpgradeImpact, onNavigateToHelmRelease, onNavigateToManagerPath, onShowWhatsNew }: HomeViewProps) {
58
60
  // The card itself decides whether the cluster has a capacity story
59
61
  // (available, softened-denied, or karpenterless-with-managers/groups) and
60
62
  // returns null otherwise — the outer gate only excludes states with nothing
@@ -138,9 +140,11 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
138
140
  )}
139
141
  {/* Row 1: Cluster Health Card (combined health + resource counts) */}
140
142
  <ClusterHealthCard
141
- radarVersion={deploymentMode === 'in-cluster' && versionInfo ? (
143
+ radarVersion={versionInfo && deploymentMode !== 'cloud' ? (
142
144
  <RadarVersionLine
143
145
  version={versionInfo}
146
+ showUpgrade={deploymentMode === 'in-cluster'}
147
+ onShowWhatsNew={onShowWhatsNew}
144
148
  manager={installationManager}
145
149
  managerLoading={installationManagerLoading}
146
150
  onNavigateToHelmRelease={onNavigateToHelmRelease}
@@ -142,4 +142,18 @@ describe('RadarVersionLine', () => {
142
142
  expect(html).toContain('Open the in-cluster upgrade instructions and apply the change through GitOps')
143
143
  expect(html).not.toContain('Open it to upgrade through GitOps')
144
144
  })
145
+
146
+ it('offers What\'s new only when the host can open it', () => {
147
+ const upToDate = { ...version, latestVersion: '1.2.3', updateAvailable: false }
148
+ expect(renderToString(<RadarVersionLine version={upToDate} />)).not.toContain('What&#x27;s new')
149
+ expect(renderToString(<RadarVersionLine version={upToDate} onShowWhatsNew={() => {}} />)).toContain('What&#x27;s new')
150
+ expect(renderToString(<RadarVersionLine version={version} onShowWhatsNew={() => {}} />)).toContain('What&#x27;s new')
151
+ })
152
+
153
+ it('hides upgrade guidance when the host handles upgrades elsewhere', () => {
154
+ const html = renderToString(<RadarVersionLine version={version} showUpgrade={false} onShowWhatsNew={() => {}} />)
155
+ expect(html).toContain('v1.2.3')
156
+ expect(html).not.toContain('available')
157
+ expect(html).toContain('What&#x27;s new')
158
+ })
145
159
  })
@@ -1,4 +1,4 @@
1
- import { ArrowUpCircle } from 'lucide-react'
1
+ import { ArrowUpCircle, Megaphone } from 'lucide-react'
2
2
  import { gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
3
3
  import type { CloudConnectSelf, VersionInfo } from '../../api/client'
4
4
  import {
@@ -14,6 +14,10 @@ interface RadarVersionLineProps {
14
14
  managerLoading?: boolean
15
15
  onNavigateToHelmRelease?: (namespace: string, release: string) => void
16
16
  onNavigateToGitOps?: (path: string) => void
17
+ // Local installs upgrade through the update notification, and this line's
18
+ // upgrade link points at in-cluster instructions.
19
+ showUpgrade?: boolean
20
+ onShowWhatsNew?: () => void
17
21
  }
18
22
 
19
23
  function displayVersion(version: string): string {
@@ -26,13 +30,33 @@ export function RadarVersionLine({
26
30
  managerLoading = false,
27
31
  onNavigateToHelmRelease,
28
32
  onNavigateToGitOps,
33
+ showUpgrade: upgradeAllowed = true,
34
+ onShowWhatsNew,
29
35
  }: RadarVersionLineProps) {
30
36
  const latestVersion = version.latestVersion
31
37
  const updateStatus = getVersionUpdateStatus(version.currentVersion, latestVersion)
32
- const showUpgrade = version.updateAvailable && !!latestVersion && updateStatus.tier !== 'none'
38
+ const showUpgrade = upgradeAllowed && version.updateAvailable && !!latestVersion && updateStatus.tier !== 'none'
39
+ const whatsNew = onShowWhatsNew && (
40
+ <span className="inline-flex items-center gap-1">
41
+ <span aria-hidden>·</span>
42
+ <button
43
+ type="button"
44
+ onClick={onShowWhatsNew}
45
+ className="inline-flex items-center gap-1 text-accent-text hover:text-accent transition-colors"
46
+ >
47
+ <Megaphone className="h-3.5 w-3.5 shrink-0" aria-hidden />
48
+ What's new
49
+ </button>
50
+ </span>
51
+ )
33
52
 
34
53
  if (!showUpgrade) {
35
- return <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
54
+ return (
55
+ <span className="inline-flex flex-wrap items-center gap-x-1">
56
+ <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
57
+ {whatsNew}
58
+ </span>
59
+ )
36
60
  }
37
61
 
38
62
  const controller = manager?.controllerRef
@@ -111,6 +135,7 @@ export function RadarVersionLine({
111
135
  {action}
112
136
  </Tooltip>
113
137
  </span>
138
+ {whatsNew}
114
139
  </span>
115
140
  )
116
141
  }
@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest";
2
2
  import { renderToString } from "react-dom/server";
3
3
  import { PrimaryNavRail } from "./PrimaryNavRail";
4
4
 
5
- function renderRail() {
5
+ function renderRail(whatsNew?: { unread: boolean; onOpen: () => void }) {
6
6
  return renderToString(
7
7
  <PrimaryNavRail
8
8
  activeView="home"
9
9
  onNavigate={() => {}}
10
10
  pinned
11
11
  onTogglePinned={() => {}}
12
+ whatsNew={whatsNew}
12
13
  />,
13
14
  );
14
15
  }
@@ -17,4 +18,12 @@ describe("PrimaryNavRail", () => {
17
18
  it("always surfaces Capacity — the view reads cluster capacity across every node manager, not just Karpenter", () => {
18
19
  expect(renderRail()).toContain("Capacity");
19
20
  });
21
+
22
+ it("shows What's new only when the host has notes, and marks it while unread", () => {
23
+ expect(renderRail()).not.toContain("What&#x27;s new");
24
+ const read = renderRail({ unread: false, onOpen: () => {} });
25
+ expect(read).toContain("What&#x27;s new");
26
+ expect(read).not.toContain("(unread)");
27
+ expect(renderRail({ unread: true, onOpen: () => {} })).toContain("(unread)");
28
+ });
20
29
  });
@@ -14,6 +14,7 @@ import {
14
14
  Gauge,
15
15
  ShieldCheck,
16
16
  Settings,
17
+ Megaphone,
17
18
  PanelLeftClose,
18
19
  PanelLeftOpen,
19
20
  } from "lucide-react";
@@ -97,6 +98,8 @@ interface PrimaryNavRailProps {
97
98
  // which self-nulls without auth so the row vanishes in no-auth OSS).
98
99
  onOpenSettings?: () => void;
99
100
  accountSlot?: ReactNode;
101
+ // Present only when release notes exist for this version; unread adds a dot.
102
+ whatsNew?: { unread: boolean; onOpen: () => void };
100
103
  }
101
104
 
102
105
  export function PrimaryNavRail({
@@ -107,6 +110,7 @@ export function PrimaryNavRail({
107
110
  showPinToggle = true,
108
111
  onOpenSettings,
109
112
  accountSlot,
113
+ whatsNew,
110
114
  }: PrimaryNavRailProps) {
111
115
  return (
112
116
  <aside
@@ -157,6 +161,15 @@ export function PrimaryNavRail({
157
161
  accountSlot self-nulls without auth, so no-auth OSS shows only Settings. */}
158
162
  <nav className="flex flex-col gap-0.5 px-2 pt-1 border-t border-theme-border/50">
159
163
  {accountSlot}
164
+ {whatsNew && (
165
+ <RailActionRow
166
+ icon={Megaphone}
167
+ label="What's new"
168
+ pinned={pinned}
169
+ onClick={whatsNew.onOpen}
170
+ unread={whatsNew.unread}
171
+ />
172
+ )}
160
173
  {onOpenSettings && (
161
174
  <RailActionRow
162
175
  icon={Settings}
@@ -337,11 +350,13 @@ export function RailActionRow({
337
350
  label,
338
351
  pinned,
339
352
  onClick,
353
+ unread = false,
340
354
  }: {
341
355
  icon: ComponentType<{ className?: string }>;
342
356
  label: string;
343
357
  pinned: boolean;
344
358
  onClick: () => void;
359
+ unread?: boolean;
345
360
  }) {
346
361
  return (
347
362
  <div className={clsx("group/item relative", !pinned && "w-10")}>
@@ -353,11 +368,18 @@ export function RailActionRow({
353
368
  !pinned && "max-w-10 overflow-hidden",
354
369
  )}
355
370
  >
356
- <span className="flex w-10 shrink-0 items-center justify-center">
357
- <Icon className="w-[18px] h-[18px] text-theme-text-tertiary group-hover/item:text-theme-text-secondary" />
371
+ <span className="relative flex w-10 shrink-0 items-center justify-center">
372
+ <Icon className={clsx(
373
+ "w-[18px] h-[18px] group-hover/item:text-theme-text-secondary",
374
+ unread ? "text-accent" : "text-theme-text-tertiary",
375
+ )} />
376
+ {unread && (
377
+ <span aria-hidden className="absolute top-1.5 right-2 h-2 w-2 rounded-full bg-accent ring-2 ring-theme-sidebar" />
378
+ )}
358
379
  </span>
359
- <span className={clsx("pr-3 truncate", !pinned && "opacity-0")}>
380
+ <span className={clsx("pr-3 truncate", !pinned && "opacity-0", unread && "text-theme-text-primary")}>
360
381
  {label}
382
+ {unread && <span className="sr-only"> (unread)</span>}
361
383
  </span>
362
384
  </button>
363
385
  {!pinned && (
@@ -0,0 +1,18 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { JobRenderer, JobSetRenderer } from './JobAdmissionRenderers'
4
+ vi.mock('../../execution/JobSetAdmission', () => ({ KueueAdmission: ({ presentation }: { presentation?: string }) => <div data-presentation={presentation}>admission evidence</div> }))
5
+ const job = { apiVersion: 'batch/v1', kind: 'Job', metadata: { name: 'training', namespace: 'ml' }, status: { conditions: [{ type: 'Failed', status: 'True', reason: 'BackoffLimitExceeded' }] } }
6
+ describe('admission renderer composition', () => {
7
+ it('keeps Job failure above admission evidence', () => {
8
+ const html = renderToStaticMarkup(<JobRenderer data={job} />)
9
+ expect(html).toContain('data-presentation="drawer"')
10
+ expect(html.indexOf('Job Issues')).toBeLessThan(html.indexOf('admission evidence'))
11
+ })
12
+ it('does not mount batch admission for a colliding Job kind', () => {
13
+ expect(renderToStaticMarkup(<JobRenderer data={{...job, apiVersion:'batch.volcano.sh/v1alpha1'}} />)).not.toContain('admission evidence')
14
+ })
15
+ it('mounts admission in the JobSet drawer', () => {
16
+ expect(renderToStaticMarkup(<JobSetRenderer data={{...job, apiVersion:'jobset.x-k8s.io/v1alpha2',kind:'JobSet'}} />)).toContain('admission evidence')
17
+ })
18
+ })
@@ -0,0 +1,18 @@
1
+ import { JobRenderer as BaseJobRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/JobRenderer'
2
+ import { JobSetRenderer as BaseJobSetRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/JobSetRenderer'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { KueueAdmission } from '../../execution/JobSetAdmission'
5
+
6
+ type Props = { data: any; onNavigate?: (ref: ResourceRef) => void }
7
+
8
+ export function JobRenderer({ data, onNavigate }: Props) {
9
+ return <BaseJobRenderer data={data} admissionContent={
10
+ data.apiVersion === 'batch/v1' && data.kind === 'Job' && <KueueAdmission presentation="drawer" resource={data} namespace={data.metadata.namespace} name={data.metadata.name} onNavigate={onNavigate} />
11
+ } />
12
+ }
13
+
14
+ export function JobSetRenderer({ data, onNavigate }: Props) {
15
+ return <BaseJobSetRenderer data={data} admissionContent={
16
+ <KueueAdmission presentation="drawer" resource={data} namespace={data.metadata.namespace} name={data.metadata.name} onNavigate={onNavigate} />
17
+ } />
18
+ }
@@ -0,0 +1,40 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { RayJobRenderer } from './RayJobRenderer'
4
+ import { ApiError } from '../../../api/client'
5
+ const mock = vi.hoisted(() => ({ query: {} as any, reads: [] as any[] }))
6
+ vi.mock('../../../api/client', () => ({ ApiError: class extends Error { constructor(message: string, public status: number) { super(message) } }, useResource: (...args: any[]) => { mock.reads.push(args); return mock.query } }))
7
+ vi.mock('../../execution/JobSetAdmission', () => ({ KueueAdmission: () => null }))
8
+ const root = { apiVersion: 'ray.io/v1', kind: 'RayJob', metadata: { name: 'train', namespace: 'ml', uid: 'root' }, spec: {} }
9
+ const child = { apiVersion: 'batch/v1', kind: 'Job', metadata: { name: 'train', namespace: 'ml', ownerReferences: [{ apiVersion: 'ray.io/v1', kind: 'RayJob', name: 'train', uid: 'root', controller: true }] }, spec: {}, status: {} }
10
+ const render = (data: any = root) => renderToStaticMarkup(<RayJobRenderer data={data} onNavigate={() => {}} />)
11
+ describe('RayJob child reads', () => {
12
+ beforeEach(() => { mock.query = {}; mock.reads = [] })
13
+ it('reads the exact submitter candidate and accepts only the controller incarnation', () => {
14
+ mock.query = { data: child }
15
+ expect(render()).toContain('Open this Job for submitter Pods')
16
+ expect(mock.reads).toEqual([['jobs', 'ml', 'train', 'batch']])
17
+ expect(render({ ...root, metadata: { ...root.metadata, uid: 'recreated' } })).toContain('ownership mismatch')
18
+ })
19
+ it.each([
20
+ { ...child, apiVersion: 'batch.volcano.sh/v1alpha1' },
21
+ { ...child, metadata: { ...child.metadata, namespace: 'other' } },
22
+ { ...child, metadata: { ...child.metadata, ownerReferences: [{ ...child.metadata.ownerReferences[0], controller: false }] } },
23
+ ])('rejects wrong identity', data => { mock.query = { data }; expect(render()).toContain('ownership mismatch') })
24
+ it.each(['HTTPMode', 'SidecarMode', 'InteractiveMode', 'FutureMode'])('does not read a submitter in %s', submissionMode => { render({ ...root, spec: { submissionMode } }); expect(mock.reads).toEqual([]) })
25
+ it('does not read remote children, including selected clusters', () => { render({ ...root, spec: { managedBy: 'remote', clusterSelector: { 'ray.io/cluster': 'shared' } } }); expect(mock.reads).toEqual([]) })
26
+ it('keeps missing and denied observations distinct, even with cached data', () => {
27
+ mock.query = { data: child, error: new ApiError('not found', 404) }
28
+ expect(render()).toContain('No submitter Job observed')
29
+ mock.query.error = new ApiError('denied', 403)
30
+ expect(render()).toContain('Submitter evidence unavailable')
31
+ expect(render()).not.toContain('Open this Job')
32
+ })
33
+ it('keeps deleting submitter logs reachable without declaring a current attempt', () => {
34
+ mock.query = { data: { ...child, metadata: { ...child.metadata, deletionTimestamp: '2026-09-27T00:00:00Z' } } }
35
+ const html = render({ ...root, status: { jobDeploymentStatus: 'Retrying' } })
36
+ expect(html).toContain('Deleting')
37
+ expect(html).toContain('attempt being cleaned up')
38
+ expect(html).toContain('Open this Job')
39
+ })
40
+ })
@@ -0,0 +1,45 @@
1
+ import { Tooltip } from '@skyhook-io/k8s-ui/components/ui/Tooltip'
2
+ import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { RayJobRenderer as BaseRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RayJobRenderer'
5
+ import { rayJobIsLocal, rayJobSubmissionMode } from '@skyhook-io/k8s-ui/components/resources/resource-utils-ray'
6
+ import { getJobStatus } from '@skyhook-io/k8s-ui/components/resources/resource-utils'
7
+ import { apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
8
+ import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
9
+ import { AlertBanner, ResourceLink } from '@skyhook-io/k8s-ui/components/ui/drawer-components'
10
+ import { ApiError, useResource } from '../../../api/client'
11
+ import { KueueAdmission } from '../../execution/JobSetAdmission'
12
+
13
+ function Submitter({ root, onNavigate }: { root: any; onNavigate?: (ref: ResourceRef) => void }) {
14
+ const query = useResource<any>('jobs', root.metadata.namespace, root.metadata.name, 'batch')
15
+ if (query.error) return query.error instanceof ApiError && query.error.status === 404
16
+ ? <p className="text-theme-text-secondary">No submitter Job observed. It may not have been created yet or may have been cleaned up.</p>
17
+ : <AlertBanner variant="warning" title="Submitter evidence unavailable" message={query.error.message} />
18
+ if (!query.data) return <p className="text-theme-text-secondary">Reading submitter Job…</p>
19
+ const job = query.data
20
+ const owned = job.apiVersion === 'batch/v1' && job.kind === 'Job' && job.metadata?.name === root.metadata.name && job.metadata?.namespace === root.metadata.namespace && job.metadata.ownerReferences?.some((owner: any) => owner.controller === true && apiVersionToGroup(owner.apiVersion) === 'ray.io' && owner.kind === 'RayJob' && owner.name === root.metadata.name && owner.uid === root.metadata.uid)
21
+ if (!owned) return <AlertBanner variant="warning" title="Submitter ownership mismatch" message="The same-name Job does not report this RayJob incarnation as its controller. Its status is not attributed to this RayJob." />
22
+ const status = getJobStatus(job)
23
+ return <div className="space-y-2">
24
+ <div className="flex flex-wrap items-center gap-2"><ResourceLink kind="jobs" group="batch" namespace={job.metadata.namespace} name={job.metadata.name} onNavigate={onNavigate} />{job.metadata.deletionTimestamp ? <Badge severity="alert">Deleting</Badge> : <span className={`badge ${status.color}`}>{status.text}</span>}</div>
25
+ {job.metadata.creationTimestamp && <p className="text-xs text-theme-text-secondary">Created <Tooltip content={job.metadata.creationTimestamp}><time dateTime={job.metadata.creationTimestamp}>{formatRelativeAgeTime(job.metadata.creationTimestamp)}</time></Tooltip></p>}
26
+ <p className="text-xs text-theme-text-secondary">Open this Job for submitter Pods and logs. Ownership identifies the RayJob, not a particular attempt.</p>
27
+ {(job.metadata.deletionTimestamp || ['Retrying', 'Suspending'].includes(root.status?.jobDeploymentStatus)) && <p className="text-xs text-theme-text-secondary">The submitter may be part of the attempt being cleaned up.</p>}
28
+ </div>
29
+ }
30
+
31
+ function SelectedCluster({ namespace, name }: { namespace: string; name: string }) {
32
+ const query = useResource<any>('rayclusters', namespace, name, 'ray.io')
33
+ if (query.error) return <AlertBanner variant="warning" title="Selected cluster unavailable" message={query.error instanceof ApiError && query.error.status === 404 ? 'The selected existing RayCluster was not found. RayJob initialization requires that cluster.' : query.error.message} />
34
+ if (!query.data) return <p className="text-xs text-theme-text-secondary">Checking selected RayCluster…</p>
35
+ return <p className="text-xs text-theme-text-secondary">Selected RayCluster is present. It may be shared; its health is separate from this application.</p>
36
+ }
37
+
38
+ export function RayJobRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
39
+ const local = rayJobIsLocal(data)
40
+ const selected = data.spec?.clusterSelector?.['ray.io/cluster']
41
+ return <BaseRenderer data={data} onNavigate={onNavigate}
42
+ admissionContent={<KueueAdmission resource={data} namespace={data.metadata.namespace} name={data.metadata.name} onNavigate={onNavigate} presentation="card" />}
43
+ submitterEvidence={local && rayJobSubmissionMode(data) === 'K8sJobMode' && data.metadata.uid ? <Submitter root={data} onNavigate={onNavigate} /> : undefined}
44
+ selectedClusterEvidence={local && selected ? <SelectedCluster namespace={data.metadata.namespace} name={selected} /> : undefined} />
45
+ }