@skyhook-io/radar-app 1.14.5 → 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 (117) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +142 -334
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.admission.test.ts +34 -0
  6. package/src/api/client.jobset.test.ts +18 -0
  7. package/src/api/client.resource-identity.test.ts +44 -0
  8. package/src/api/client.rightsizing.test.ts +7 -3
  9. package/src/api/client.ts +267 -78
  10. package/src/api/preferences.test.ts +54 -0
  11. package/src/api/preferences.ts +38 -0
  12. package/src/api/usage-data.fixtures.ts +33 -0
  13. package/src/api/usage-data.ts +165 -0
  14. package/src/components/CloudFunnelButton.test.tsx +155 -0
  15. package/src/components/CloudFunnelButton.tsx +113 -63
  16. package/src/components/ContextSwitcher.tsx +13 -132
  17. package/src/components/SelfManagedStart.tsx +96 -0
  18. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  19. package/src/components/applications/ApplicationsView.tsx +5 -3
  20. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  21. package/src/components/cloudConnectHandoff.test.ts +1 -1
  22. package/src/components/cloudConnectHandoff.ts +1 -1
  23. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  24. package/src/components/diagnose/AgentControls.tsx +26 -3
  25. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  26. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  27. package/src/components/diagnose/agentCatalog.ts +6 -0
  28. package/src/components/diagnose/launch.test.ts +23 -0
  29. package/src/components/diagnose/launch.ts +4 -0
  30. package/src/components/diagnose/parts.test.tsx +18 -0
  31. package/src/components/dialog-names.test.ts +69 -0
  32. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  33. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  34. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  35. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  36. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  37. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  38. package/src/components/execution/BatchExecutionView.tsx +324 -65
  39. package/src/components/execution/JobSetAdmission.test.tsx +165 -0
  40. package/src/components/execution/JobSetAdmission.tsx +26 -0
  41. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  42. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  43. package/src/components/execution/batch-run-actions.test.ts +1 -0
  44. package/src/components/execution/batch-timeline.test.ts +3 -0
  45. package/src/components/execution/execution-definition.test.ts +15 -1
  46. package/src/components/execution/execution-definition.ts +4 -0
  47. package/src/components/execution/member-collection.render.test.tsx +142 -0
  48. package/src/components/gitops/GitOpsView.tsx +33 -2
  49. package/src/components/gitops/destination-toast.test.ts +33 -0
  50. package/src/components/gitops/destination-toast.ts +46 -0
  51. package/src/components/gitops/useDestinationCluster.ts +30 -0
  52. package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
  53. package/src/components/home/HomeView.tsx +6 -2
  54. package/src/components/home/MCPSetupDialog.tsx +10 -0
  55. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  56. package/src/components/home/RadarVersionLine.tsx +28 -3
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  60. package/src/components/nav/PrimaryNavRail.tsx +27 -8
  61. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  62. package/src/components/resource/PVCUsageBar.tsx +62 -16
  63. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  64. package/src/components/resources/ResourcesView.tsx +3 -3
  65. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  66. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  67. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  70. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  71. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  75. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  76. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  77. package/src/components/rightsizing/copy.test.ts +7 -0
  78. package/src/components/rightsizing/model.test.ts +2 -1
  79. package/src/components/rightsizing/notices.test.tsx +4 -3
  80. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  81. package/src/components/settings/PrivacySection.test.tsx +84 -0
  82. package/src/components/settings/PrivacySection.tsx +140 -0
  83. package/src/components/settings/SettingsDialog.tsx +168 -92
  84. package/src/components/settings/controls.tsx +55 -0
  85. package/src/components/settings/settings-state.test.ts +18 -0
  86. package/src/components/settings/settings-state.ts +19 -0
  87. package/src/components/timeline/TimelineView.tsx +9 -3
  88. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  89. package/src/components/ui/ErrorBoundary.tsx +5 -0
  90. package/src/components/ui/Omnibar.tsx +16 -0
  91. package/src/components/ui/UpdateNotification.tsx +13 -9
  92. package/src/components/ui/command-items.ts +14 -3
  93. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  94. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  95. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  96. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  97. package/src/components/useContextSwitchFlow.tsx +147 -0
  98. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  99. package/src/components/whats-new/WhatsNew.tsx +423 -0
  100. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  101. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  102. package/src/components/whats-new/releaseNotes.ts +121 -0
  103. package/src/components/workload/WorkloadView.test.ts +17 -1
  104. package/src/components/workload/WorkloadView.tsx +47 -19
  105. package/src/context/ContextSwitchContext.tsx +18 -2
  106. package/src/context/NavCustomization.tsx +4 -58
  107. package/src/context/ThemeContext.tsx +3 -11
  108. package/src/hooks/useClusterLoadState.ts +2 -2
  109. package/src/hooks/useFavorites.ts +3 -5
  110. package/src/k8s-ui-exports.test.ts +73 -0
  111. package/src/utils/auditBadges.ts +1 -1
  112. package/src/utils/navigation.test.ts +34 -1
  113. package/src/utils/navigation.ts +28 -5
  114. package/src/utils/topology-selection.test.ts +56 -0
  115. package/src/utils/topology-selection.ts +9 -9
  116. package/src/utils/version.ts +11 -0
  117. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -0,0 +1,229 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { focusManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
+ import { MemoryRouter } from 'react-router-dom'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import { Megaphone } from 'lucide-react'
8
+ import type { WhatsNewState } from '../../api/client'
9
+ import { openWhatsNew, useWhatsNewStatus, WhatsNew } from './WhatsNew'
10
+ import { RELEASE_NOTES } from './releaseNotes'
11
+
12
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
13
+
14
+ let root: Root
15
+ let client: QueryClient
16
+ let serverState: WhatsNewState
17
+ const seenPosts: string[] = []
18
+ let failSeenPosts = 0
19
+ let pendingRead: ((state: WhatsNewState) => void) | null = null
20
+ let holdNextRead = false
21
+ const status = { current: { available: false, unread: false } }
22
+
23
+ function memoryStorage(): Storage {
24
+ const items = new Map<string, string>()
25
+ return {
26
+ get length() { return items.size },
27
+ key: (i: number) => Array.from(items.keys())[i] ?? null,
28
+ getItem: (k: string) => items.get(k) ?? null,
29
+ setItem: (k: string, v: string) => { items.set(k, String(v)) },
30
+ removeItem: (k: string) => { items.delete(k) },
31
+ clear: () => items.clear(),
32
+ }
33
+ }
34
+
35
+ function StatusProbe() {
36
+ status.current = useWhatsNewStatus()
37
+ return null
38
+ }
39
+
40
+ beforeEach(() => {
41
+ RELEASE_NOTES.push({
42
+ version: 'v1.15.0',
43
+ releaseUrl: 'https://github.com/skyhook-io/radar/releases',
44
+ highlights: [{ id: 'x', icon: Megaphone, title: 'Capacity views', description: 'd' }],
45
+ improvements: [],
46
+ })
47
+ seenPosts.length = 0
48
+ failSeenPosts = 0
49
+ pendingRead = null
50
+ holdNextRead = false
51
+ vi.stubGlobal('localStorage', memoryStorage())
52
+ // Radar's own defaults: focus refetch is off unless a query opts in.
53
+ client = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } })
54
+ const element = document.createElement('div')
55
+ document.body.appendChild(element)
56
+ root = createRoot(element)
57
+ vi.stubGlobal('fetch', vi.fn(async (input: string, init?: RequestInit) => {
58
+ const path = new URL(input, 'http://localhost').pathname
59
+ if (path === '/api/capabilities') return Response.json({ deployment: { mode: serverState.storage === 'server' ? 'local' : 'in-cluster' } })
60
+ if (path === '/api/whats-new') {
61
+ const snapshot = { ...serverState }
62
+ if (holdNextRead) {
63
+ holdNextRead = false
64
+ return new Promise<Response>(resolve => { pendingRead = s => resolve(Response.json(s)) })
65
+ }
66
+ return Response.json(snapshot)
67
+ }
68
+ if (path === '/api/whats-new/seen' && init?.method === 'POST') {
69
+ seenPosts.push(JSON.parse(String(init.body)).version)
70
+ if (failSeenPosts > 0) {
71
+ failSeenPosts--
72
+ return Response.json({ error: 'failed to record the seen version' }, { status: 500 })
73
+ }
74
+ serverState = { ...serverState, seenVersion: seenPosts[seenPosts.length - 1] }
75
+ return new Response(null, { status: 204 })
76
+ }
77
+ throw new Error(`Unexpected request: ${input}`)
78
+ }))
79
+ })
80
+
81
+ afterEach(async () => {
82
+ await act(async () => root.unmount())
83
+ RELEASE_NOTES.length = 0
84
+ client.clear()
85
+ document.body.replaceChildren()
86
+ vi.unstubAllGlobals()
87
+ })
88
+
89
+ async function render(path: string) {
90
+ await act(async () => {
91
+ root.render(
92
+ <QueryClientProvider client={client}>
93
+ <MemoryRouter initialEntries={[path]}>
94
+ <WhatsNew onNavigate={() => {}} />
95
+ <StatusProbe />
96
+ </MemoryRouter>
97
+ </QueryClientProvider>,
98
+ )
99
+ })
100
+ await vi.waitFor(async () => {
101
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
102
+ expect(status.current.available).toBe(true)
103
+ })
104
+ }
105
+
106
+ const dialog = () => document.querySelector('[role="dialog"][aria-modal="true"]')
107
+
108
+ async function clickGotIt() {
109
+ const gotIt = Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Got it')!
110
+ await act(async () => { gotIt.click() })
111
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
112
+ }
113
+
114
+ describe('WhatsNew', () => {
115
+ it('auto-opens on Home after an upgrade and records the running version on close', async () => {
116
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
117
+ await render('/')
118
+ expect(dialog()?.textContent).toContain('v1.14.1')
119
+ expect(status.current.unread).toBe(true)
120
+ await clickGotIt()
121
+ expect(seenPosts).toEqual(['v1.15.2'])
122
+ expect(status.current.unread).toBe(false)
123
+ })
124
+
125
+ it('stays closed on a deep link, leaving the notes unread until opened on demand', async () => {
126
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
127
+ await render('/resources/pods')
128
+ expect(dialog()).toBeNull()
129
+ expect(status.current.unread).toBe(true)
130
+ expect(seenPosts).toEqual([])
131
+ await act(async () => { openWhatsNew() })
132
+ expect(dialog()).not.toBeNull()
133
+ await clickGotIt()
134
+ expect(seenPosts).toEqual(['v1.15.2'])
135
+ expect(status.current.unread).toBe(false)
136
+ })
137
+
138
+ it('records a fresh install without opening', async () => {
139
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', priorInstall: false }
140
+ await render('/')
141
+ expect(dialog()).toBeNull()
142
+ expect(seenPosts).toEqual(['v1.15.2'])
143
+ expect(status.current.unread).toBe(false)
144
+ })
145
+
146
+ it('keeps the in-cluster record in the browser, never on the server', async () => {
147
+ serverState = { currentVersion: 'v1.15.2', storage: 'browser' }
148
+ localStorage.setItem('radar-whats-new-seen', 'v1.14.1')
149
+ await render('/')
150
+ expect(dialog()).not.toBeNull()
151
+ await clickGotIt()
152
+ expect(localStorage.getItem('radar-whats-new-seen')).toBe('v1.15.2')
153
+ expect(seenPosts).toEqual([])
154
+ })
155
+
156
+ it('records nothing when previewing another release', async () => {
157
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.15.2', priorInstall: true }
158
+ RELEASE_NOTES.push({ ...RELEASE_NOTES[0], version: 'v1.14.0' })
159
+ serverState.seenVersion = 'v1.14.1'
160
+ await render('/resources/pods?whats-new=v1.14.0')
161
+ expect(dialog()?.textContent).toContain("What's new in Radar v1.14")
162
+ await clickGotIt()
163
+ expect(seenPosts).toEqual([])
164
+ expect(status.current.unread).toBe(true)
165
+ })
166
+
167
+ it('keeps the notes unread when recording fails, and retries on the next close', async () => {
168
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
169
+ failSeenPosts = 1
170
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
171
+ await render('/')
172
+ await clickGotIt()
173
+ expect(seenPosts).toEqual(['v1.15.2'])
174
+ expect(status.current.unread).toBe(true)
175
+ await act(async () => { openWhatsNew() })
176
+ await clickGotIt()
177
+ expect(seenPosts).toEqual(['v1.15.2', 'v1.15.2'])
178
+ expect(status.current.unread).toBe(false)
179
+ })
180
+
181
+ it('clears the dot when another tab acknowledges the notes', async () => {
182
+ serverState = { currentVersion: 'v1.15.2', storage: 'browser' }
183
+ localStorage.setItem('radar-whats-new-seen', 'v1.14.1')
184
+ await render('/resources/pods')
185
+ expect(status.current.unread).toBe(true)
186
+ localStorage.setItem('radar-whats-new-seen', 'v1.15.2')
187
+ await act(async () => { window.dispatchEvent(new Event('storage')) })
188
+ expect(status.current.unread).toBe(false)
189
+ })
190
+
191
+ it('picks up another tab\'s acknowledgment when this window regains focus', async () => {
192
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
193
+ await render('/resources/pods')
194
+ expect(status.current.unread).toBe(true)
195
+ serverState = { ...serverState, seenVersion: 'v1.15.2' }
196
+ await act(async () => { focusManager.setFocused(false); focusManager.setFocused(true) })
197
+ await vi.waitFor(() => expect(status.current.unread).toBe(false))
198
+ focusManager.setFocused(undefined)
199
+ })
200
+
201
+ it('does not let a read that started before the acknowledgment undo it', async () => {
202
+ serverState = { currentVersion: 'v1.15.2', storage: 'server', seenVersion: 'v1.14.1', priorInstall: true }
203
+ await render('/')
204
+ const staleSnapshot = { ...serverState }
205
+ holdNextRead = true
206
+ await act(async () => { void client.refetchQueries({ queryKey: ['whats-new'] }) })
207
+ await vi.waitFor(() => expect(pendingRead).not.toBeNull())
208
+ await clickGotIt()
209
+ await vi.waitFor(() => expect(status.current.unread).toBe(false))
210
+ await act(async () => { pendingRead!(staleSnapshot) })
211
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)) })
212
+ expect(status.current.unread).toBe(false)
213
+ })
214
+
215
+ it('never records a development build', async () => {
216
+ serverState = { currentVersion: 'dev', storage: 'server', priorInstall: false }
217
+ RELEASE_NOTES.push({ ...RELEASE_NOTES[0], version: 'v0.0.0' })
218
+ await act(async () => {
219
+ root.render(
220
+ <QueryClientProvider client={client}>
221
+ <MemoryRouter initialEntries={['/']}><WhatsNew onNavigate={() => {}} /><StatusProbe /></MemoryRouter>
222
+ </QueryClientProvider>,
223
+ )
224
+ })
225
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)) })
226
+ expect(dialog()).toBeNull()
227
+ expect(seenPosts).toEqual([])
228
+ })
229
+ })
@@ -0,0 +1,65 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { mkdtempSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ // scripts/check-whats-new.sh gates every minor release on having notes; these
8
+ // pin what counts as an entry, against catalogs written to a temp dir.
9
+ const script = join(__dirname, '..', '..', '..', '..', 'scripts', 'check-whats-new.sh')
10
+
11
+ function check(version: string, catalog: string) {
12
+ const file = join(mkdtempSync(join(tmpdir(), 'whats-new-')), 'releaseNotes.ts')
13
+ writeFileSync(file, catalog)
14
+ return spawnSync('bash', [script, version], { env: { ...process.env, WHATS_NEW_CATALOG: file }, encoding: 'utf8' }).status
15
+ }
16
+
17
+ const empty = `export const RELEASE_NOTES: ReleaseNotes[] = []\n`
18
+ const withEntry = (entry: string, before = '') => `${before}export const RELEASE_NOTES: ReleaseNotes[] = [\n${entry}\n]\n\nexport function after() {}\n`
19
+
20
+ describe('check-whats-new.sh', () => {
21
+ it('lets patches and prereleases through without notes', () => {
22
+ expect(check('v1.15.1', empty)).toBe(0)
23
+ expect(check('v1.15.0-rc.1', empty)).toBe(0)
24
+ })
25
+
26
+ it('refuses a minor or major release without notes, including one with build metadata', () => {
27
+ expect(check('v1.15.0', empty)).toBe(1)
28
+ expect(check('v2.0.0', empty)).toBe(1)
29
+ expect(check('v1.15.0+build.1', empty)).toBe(1)
30
+ })
31
+
32
+ it('refuses a version that is not vX.Y.Z', () => {
33
+ expect(check('v1.15', empty)).toBe(1)
34
+ expect(check('latest', empty)).toBe(1)
35
+ })
36
+
37
+ it('accepts a real entry, wherever version sits in it', () => {
38
+ const entry = ` {\n releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.15.0', version: 'v1.15.0',\n highlights: [],\n },`
39
+ expect(check('v1.15.0', withEntry(entry))).toBe(0)
40
+ expect(check('1.15.0+build.1', withEntry(entry))).toBe(0)
41
+ })
42
+
43
+ it('does not count comments, objects outside the catalog, or look-alike properties', () => {
44
+ const decoys = `// { version: 'v1.16.0' },\n/* { version: 'v1.17.0' } */\nconst draft = { version: 'v1.18.0' }\n`
45
+ const catalog = withEntry(` // { version: 'v1.20.0' },\n { notversion: 'v1.19.0' },`, decoys)
46
+ for (const v of ['v1.16.0', 'v1.17.0', 'v1.18.0', 'v1.19.0', 'v1.20.0']) expect(check(v, catalog), v).toBe(1)
47
+ })
48
+
49
+ it('ignores an object after an empty catalog, even with a semicolon', () => {
50
+ expect(check('v1.15.0', `export const RELEASE_NOTES: ReleaseNotes[] = [];\nconst draft = { version: 'v1.15.0' };\n`)).toBe(1)
51
+ })
52
+
53
+ it('does not count the words "version:" inside another entry\'s text', () => {
54
+ const entry = ` {\n version: 'v1.14.0',\n improvements: ["Set version: 'v1.15.0' in your configuration"],\n },`
55
+ expect(check('v1.15.0', withEntry(entry))).toBe(1)
56
+ })
57
+
58
+ it('accepts a value on the line after its key, quoted keys, and space before the colon', () => {
59
+ expect(check('v1.15.0', withEntry(` {\n version:\n 'v1.15.0',\n },`))).toBe(0)
60
+ expect(check('v1.15.0', withEntry(` { version: "v1.15.0" },`))).toBe(0)
61
+ expect(check('v1.15.0', withEntry(` { "version": "v1.15.0" },`))).toBe(0)
62
+ expect(check('v1.15.0', withEntry(` { version : 'v1.15.0' },`))).toBe(0)
63
+ expect(check('v1.15.0', withEntry(` {\n "version" :\n "v1.15.0",\n },`))).toBe(0)
64
+ })
65
+ })
@@ -0,0 +1,121 @@
1
+ import type { LucideIcon } from 'lucide-react'
2
+ import { Bot, GitCompareArrows, Layers, Network, Scale } from 'lucide-react'
3
+ import { compareVersions } from '../../utils/version'
4
+
5
+ export interface ReleaseHighlight {
6
+ id: string
7
+ icon: LucideIcon
8
+ title: string
9
+ description: string
10
+ /** In-app route the highlight's call to action opens. */
11
+ path?: string
12
+ cta?: string
13
+ /** Icon tile color, so the cards read apart at a glance. The lead card always uses the accent. */
14
+ tone?: HighlightTone
15
+ }
16
+
17
+ export type HighlightTone = 'violet' | 'teal' | 'emerald' | 'indigo'
18
+
19
+ export interface ReleaseNotes {
20
+ version: string
21
+ /** The first highlight leads the dialog at full width. */
22
+ highlights: ReleaseHighlight[]
23
+ improvements: string[]
24
+ releaseUrl: string
25
+ }
26
+
27
+ // One entry per release that has something to announce. A version without its
28
+ // own entry shows the newest entry at or below it, so a patch upgrade (or one
29
+ // that skips the release with notes) still gets them. Every minor and major
30
+ // release must have its own entry: scripts/check-whats-new.sh refuses to
31
+ // release one without it.
32
+ export const RELEASE_NOTES: ReleaseNotes[] = [
33
+ {
34
+ version: 'v1.15.0',
35
+ releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.15.0',
36
+ highlights: [
37
+ {
38
+ id: 'ai-investigations',
39
+ icon: Bot,
40
+ title: 'AI investigations that show their work',
41
+ // No link: the investigations workspace redirects home when this run
42
+ // mode can't host local agents.
43
+ description: 'A clear verdict and the story of what broke, with the charts, logs and config behind it placed where the agent cites them. Now also runs on OpenCode, including AWS Bedrock.',
44
+ },
45
+ {
46
+ id: 'gitops-manifest-diff',
47
+ icon: GitCompareArrows,
48
+ title: 'Full manifest diffs for Argo CD resources',
49
+ description: 'Compare the Git-rendered and live manifest of a drifted Argo CD resource, side by side or unified.',
50
+ tone: 'violet',
51
+ path: '/gitops',
52
+ cta: 'Open GitOps',
53
+ },
54
+ {
55
+ id: 'rightsizing-risks-first',
56
+ icon: Scale,
57
+ title: 'Rightsizing and cost data in MCP',
58
+ description: 'Agents get new optimized tools. Risks (OOM, limit conflicts and throttling) lead before cost-saving, in the UI as well.',
59
+ tone: 'emerald',
60
+ path: '/cost/rightsizing',
61
+ cta: 'Open Rightsizing',
62
+ },
63
+ {
64
+ id: 'batch-ai-workloads',
65
+ icon: Layers,
66
+ title: 'Deeper batch + AI/ML workloads support',
67
+ description: 'Dedicated pages for Kueue, JobSet and Ray resources, and Job and JobSet details now show why a queued job has no Pods.',
68
+ tone: 'teal',
69
+ },
70
+ {
71
+ id: 'rollout-traffic-topology',
72
+ icon: Network,
73
+ title: 'Canary and blue-green in Topology',
74
+ description: 'See canary and stable Pods, which Services route to each side, and the canary traffic weight.',
75
+ tone: 'indigo',
76
+ path: '/topology',
77
+ cta: 'Open Topology',
78
+ },
79
+ ],
80
+ improvements: [
81
+ // The grid fills row by row: pair lines of similar length.
82
+ 'Resource tables remember your sort for each kind',
83
+ 'Install Helm charts from OCI registries',
84
+ 'Checks flag expired and expiring TLS certificates',
85
+ 'Radar Desktop keeps your preferences across restarts',
86
+ 'Issues show where a failing Pod differs from its template',
87
+ 'PVC details say why usage data is unavailable',
88
+ 'Issues catch Strimzi connector and admission webhook failures',
89
+ 'Bind the web UI to a specific IP with --listen-address',
90
+ ],
91
+ },
92
+ ]
93
+
94
+ export function releaseNotesFor(
95
+ version: string | undefined,
96
+ catalog: ReleaseNotes[] = RELEASE_NOTES,
97
+ ): ReleaseNotes | undefined {
98
+ if (!version) return undefined
99
+ const normalized = version.startsWith('v') ? version : `v${version}`
100
+ return catalog.find(notes => notes.version === normalized)
101
+ }
102
+
103
+ /** The release line a catalog entry covers, e.g. v1.15 for v1.15.0: its patches show the same notes. */
104
+ export function releaseLine(version: string): string {
105
+ return version.replace(/^(v\d+\.\d+)\.\d+$/, '$1')
106
+ }
107
+
108
+ /** The newest notes for a release at or below `version` — what that version's user has access to. */
109
+ export function latestReleaseNotesFor(
110
+ version: string | undefined,
111
+ catalog: ReleaseNotes[] = RELEASE_NOTES,
112
+ ): ReleaseNotes | undefined {
113
+ if (!version) return undefined
114
+ let latest: ReleaseNotes | undefined
115
+ for (const notes of catalog) {
116
+ const atOrBelow = compareVersions(notes.version, version)
117
+ if (atOrBelow === null || atOrBelow > 0) continue
118
+ if (!latest || (compareVersions(notes.version, latest.version) ?? 0) > 0) latest = notes
119
+ }
120
+ return latest
121
+ }
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import { gitOpsOwnerFromRelationships } from '@skyhook-io/k8s-ui'
4
4
  import type { Relationships } from '../../types'
5
- import { findInheritedGitOpsLookupRef } from './WorkloadView'
5
+ import { findInheritedGitOpsLookupRef, supportsBatchExecution } from './WorkloadView'
6
6
 
7
7
  describe('findInheritedGitOpsLookupRef', () => {
8
8
  it('follows a referenced ReplicaSet to its parent workload for inherited ownership', () => {
@@ -58,3 +58,19 @@ describe('findInheritedGitOpsLookupRef', () => {
58
58
  ).toBeNull()
59
59
  })
60
60
  })
61
+
62
+ describe('supportsBatchExecution', () => {
63
+ it('enables only the supported JobSet API identity', () => {
64
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'jobset.x-k8s.io', 'jobset.x-k8s.io/v1alpha2')).toBe(true)
65
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'jobset.x-k8s.io')).toBe(false)
66
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'example.io', 'example.io/v1alpha2')).toBe(false)
67
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'example.io', 'jobset.x-k8s.io/v1alpha2')).toBe(false)
68
+ expect(supportsBatchExecution('JobSet', 'jobsets', 'jobset.x-k8s.io', 'jobset.x-k8s.io/v1beta1')).toBe(false)
69
+ })
70
+
71
+ it('preserves the core Job collision guard and existing scheduled kinds', () => {
72
+ expect(supportsBatchExecution('Job', 'jobs', 'batch')).toBe(true)
73
+ expect(supportsBatchExecution('Job', 'jobs', 'example.io')).toBe(false)
74
+ expect(supportsBatchExecution('CronJob', 'cronjobs', 'batch')).toBe(true)
75
+ })
76
+ })
@@ -1,3 +1,8 @@
1
+ import { RayJobRenderer } from '../resources/renderers/RayJobRenderer'
2
+ import { JobRenderer, JobSetRenderer } from '../resources/renderers/JobAdmissionRenderers'
3
+ import { RayClusterRenderer } from '../resources/renderers/RayClusterRenderer'
4
+ import { RayServiceRenderer } from '../resources/renderers/RayServiceRenderer'
5
+ import { KueueWorkloadRenderer } from '../resources/renderers/KueueWorkloadRenderer'
1
6
  import { useMemo, useEffect, useCallback, useRef, useState } from 'react'
2
7
  import { useQueries, useQueryClient } from '@tanstack/react-query'
3
8
  import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
@@ -29,6 +34,7 @@ import {
29
34
  type WorkloadImageTarget,
30
35
  } from '@skyhook-io/k8s-ui'
31
36
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
37
+ import { isJobSetV1Alpha2 } from '@skyhook-io/k8s-ui/components/resources/resource-utils-jobset-lws'
32
38
  import type { SelectedResource, ResourceRef, Relationships, ResourceWithRelationships } from '../../types'
33
39
  import {
34
40
  kindToPlural,
@@ -121,6 +127,7 @@ import { ServiceAccountRenderer } from '../resources/renderers/ServiceAccountRen
121
127
  import { RoleRenderer } from '../resources/renderers/RoleRenderer'
122
128
  import { RoleBindingRenderer } from '../resources/renderers/RoleBindingRenderer'
123
129
  import { NamespaceRenderer } from '../resources/renderers/NamespaceRenderer'
130
+ import { CAPIClusterRenderer } from '../resources/renderers/CAPIClusterRenderer'
124
131
  import { HPARenderer } from '../resources/renderers/HPARenderer'
125
132
  import { PVCRenderer } from '../resources/renderers/PVCRenderer'
126
133
  import { RolloutRenderer } from '../resources/renderers/RolloutRenderer'
@@ -152,10 +159,27 @@ const BATCH_EXECUTION_KINDS = new Set([
152
159
  'WorkflowTemplate',
153
160
  'ClusterWorkflowTemplate',
154
161
  'ScaledJob',
162
+ 'JobSet',
155
163
  ])
156
164
 
165
+ export function supportsBatchExecution(kind: string, apiKind: string, group?: string, apiVersion?: string): boolean {
166
+ if (!BATCH_EXECUTION_KINDS.has(kind)) return false
167
+ if (kind === 'Job') return isCoreBatchJob(apiKind, group)
168
+ if (kind === 'JobSet') {
169
+ return group === 'jobset.x-k8s.io' && isJobSetV1Alpha2({ kind, apiVersion })
170
+ }
171
+ return true
172
+ }
173
+
157
174
  // Stable reference — web renderer wrappers inject platform hooks internally
158
175
  const rendererOverrides: RendererOverrides = {
176
+ RayJobRenderer,
177
+ JobRenderer,
178
+ JobSetRenderer,
179
+ RayServiceRenderer,
180
+ RayClusterRenderer,
181
+ KueueWorkloadRenderer,
182
+ CAPIClusterRenderer,
159
183
  PodRenderer,
160
184
  KarpenterNodePoolRenderer,
161
185
  NodeRenderer,
@@ -548,18 +572,6 @@ export function WorkloadView({
548
572
  [searchParams, setSearchParams],
549
573
  )
550
574
 
551
- const batchKind = pluralToKind(apiKind)
552
- const batchExecution = BATCH_EXECUTION_KINDS.has(batchKind) &&
553
- (batchKind !== 'Job' || isCoreBatchJob(apiKind, rest.group))
554
- const batchRunsQuery = useWorkloadRuns(apiKind, namespace, name, expanded && batchExecution, {
555
- refetchActive: true,
556
- clusterScoped: batchKind === 'ClusterWorkflowTemplate',
557
- })
558
- const relatedTimelineEvents = useMemo(
559
- () => workloadRunTimelineEvents(batchRunsQuery.data?.runs ?? []),
560
- [batchRunsQuery.data?.runs],
561
- )
562
-
563
575
  // Fetch resource with relationships
564
576
  const {
565
577
  data: resourceResponse,
@@ -579,6 +591,16 @@ export function WorkloadView({
579
591
  // of itself. Fall back to the group derived from the fetched resource, then
580
592
  // to undefined (which the gate treats as "group unknown → allow").
581
593
  const effectiveGroup = rest.group || resourceGroup || undefined
594
+ const batchKind = pluralToKind(apiKind)
595
+ const batchExecution = supportsBatchExecution(batchKind, apiKind, effectiveGroup, resource?.apiVersion)
596
+ const batchRunsQuery = useWorkloadRuns(apiKind, namespace, name, expanded && batchExecution, {
597
+ refetchActive: true,
598
+ clusterScoped: batchKind === 'ClusterWorkflowTemplate',
599
+ })
600
+ const relatedTimelineEvents = useMemo(
601
+ () => batchKind === 'JobSet' ? [] : workloadRunTimelineEvents(batchRunsQuery.data?.runs ?? []),
602
+ [batchKind, batchRunsQuery.data?.runs],
603
+ )
582
604
  // Reachability for a workload IS the reachability of the Services in front of
583
605
  // it - a Deployment has no address of its own. Empty for a workload nothing
584
606
  // selects, which correctly leaves the tab hidden: there is no path to trace.
@@ -590,14 +612,20 @@ export function WorkloadView({
590
612
  const refetchResourceAndRuns = useCallback(async () => {
591
613
  await Promise.all([
592
614
  refetchResource(),
615
+ ...(apiKind === 'rayjobs' && effectiveGroup === 'ray.io' ? [
616
+ queryClient.refetchQueries({ queryKey: ['resource', 'jobs', namespace, name, 'batch'], type: 'active' }),
617
+ ...(resource?.spec?.clusterSelector?.['ray.io/cluster'] ? [queryClient.refetchQueries({ queryKey: ['resource', 'rayclusters', namespace, resource.spec.clusterSelector['ray.io/cluster'], 'ray.io'], type: 'active' })] : []),
618
+ ] : []),
619
+ queryClient.refetchQueries({ queryKey: ['kueue-admission', effectiveGroup, apiKind, namespace, name], type: 'active' }),
593
620
  queryClient.refetchQueries({
594
621
  queryKey: ['workload-runs', apiKind, namespace, name],
595
622
  }),
596
623
  queryClient.refetchQueries({
597
624
  queryKey: ['workload-pods', apiKind, namespace, name],
625
+ ...(apiKind === 'rayclusters' ? { type: 'active' as const } : {}),
598
626
  }),
599
627
  ])
600
- }, [apiKind, name, namespace, queryClient, refetchResource])
628
+ }, [apiKind, effectiveGroup, name, namespace, queryClient, refetchResource, resource?.spec?.clusterSelector])
601
629
  const podWorkloadOwner = useMemo(
602
630
  () => podWorkloadOwnerFromRelationships(apiKind, namespace, relationships, resource),
603
631
  [apiKind, namespace, relationships, resource],
@@ -761,7 +789,7 @@ export function WorkloadView({
761
789
  isLoading: resourceFocusedEventsLoading,
762
790
  k8sError: resourceFocusedK8sError,
763
791
  updatesError: resourceFocusedUpdatesError,
764
- } = useResourceEvents(apiKind, namespace, name)
792
+ } = useResourceEvents(apiKind, namespace, name, effectiveGroup)
765
793
 
766
794
  // Fetch all events for this resource's namespace (only when expanded)
767
795
  const { data: allEvents, isLoading: eventsLoading } = useChanges({
@@ -868,7 +896,7 @@ export function WorkloadView({
868
896
  // the renderer banners. The backend canonicalizes a plural kind via discovery,
869
897
  // so using the normalized API kind resolves direct links and app navigation alike.
870
898
  const { data: liveIssues, isPending: issuesPending } = useResourceIssues(apiKind, rest.group, namespace, name)
871
- const { data: auditFindings } = useResourceAudit(apiKind, namespace, name)
899
+ const { data: auditFindings } = useResourceAudit(apiKind, namespace, name, effectiveGroup)
872
900
  const hasOperationalIssues = Boolean(liveIssues?.length)
873
901
  const {
874
902
  onCompareTo,
@@ -1169,7 +1197,7 @@ export function WorkloadView({
1169
1197
  // Timeline
1170
1198
  allEvents={allEvents}
1171
1199
  relatedTimelineEvents={relatedTimelineEvents}
1172
- eventsLoading={eventsLoading || (batchExecution && batchRunsQuery.isLoading)}
1200
+ eventsLoading={eventsLoading || (batchExecution && batchKind !== 'JobSet' && batchRunsQuery.isLoading)}
1173
1201
  topology={topology}
1174
1202
  resourceFocusedK8sEvents={resourceFocusedK8sEvents}
1175
1203
  resourceFocusedUpdates={resourceFocusedUpdates}
@@ -1199,8 +1227,7 @@ export function WorkloadView({
1199
1227
  />
1200
1228
  )}
1201
1229
  renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) =>
1202
- BATCH_EXECUTION_KINDS.has(k) &&
1203
- (k !== 'Job' || isCoreBatchJob(apiKind, effectiveGroup)) &&
1230
+ supportsBatchExecution(k, apiKind, effectiveGroup, res?.apiVersion) &&
1204
1231
  res ? (
1205
1232
  <BatchExecutionFullscreen
1206
1233
  kind={k}
@@ -1561,6 +1588,7 @@ const SCHEDULED_LOG_KINDS = new Set([
1561
1588
  'WorkflowTemplate',
1562
1589
  'ClusterWorkflowTemplate',
1563
1590
  'ScaledJob',
1591
+ 'JobSet',
1564
1592
  ])
1565
1593
 
1566
1594
  function LogsTabContent({
@@ -1592,7 +1620,7 @@ function LogsTabContent({
1592
1620
  selectedRunKey: string
1593
1621
  onSelectRun: (runKey: string) => void
1594
1622
  }) {
1595
- if (SCHEDULED_LOG_KINDS.has(kind)) {
1623
+ if (SCHEDULED_LOG_KINDS.has(kind) && supportsBatchExecution(kind, apiKind, group, resource?.apiVersion)) {
1596
1624
  return (
1597
1625
  <div className="h-full">
1598
1626
  <ScheduledWorkloadLogsViewer
@@ -1,4 +1,5 @@
1
- import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
1
+ import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'
2
+ import type { SelectedResource } from '../types'
2
3
 
3
4
  export interface ParsedContextInfo {
4
5
  raw: string
@@ -15,6 +16,11 @@ interface ContextSwitchState {
15
16
  startSwitch: (context: ParsedContextInfo) => void
16
17
  updateProgress: (message: string) => void
17
18
  endSwitch: () => void
19
+ // A resource to open once a switch to `context` is live. The app's context
20
+ // reset takes it, so the open can't race the reset that clears the old
21
+ // cluster's drawer and URL. A change to any other context discards it.
22
+ setOpenAfterSwitch: (target: { context: string; resource: SelectedResource } | null) => void
23
+ takeOpenAfterSwitch: (context: string) => SelectedResource | null
18
24
  }
19
25
 
20
26
  const ContextSwitchContext = createContext<ContextSwitchState | null>(null)
@@ -40,8 +46,18 @@ export function ContextSwitchProvider({ children }: { children: ReactNode }) {
40
46
  setProgressMessage(null)
41
47
  }, [])
42
48
 
49
+ const openAfterSwitch = useRef<{ context: string; resource: SelectedResource } | null>(null)
50
+ const setOpenAfterSwitch = useCallback((target: { context: string; resource: SelectedResource } | null) => {
51
+ openAfterSwitch.current = target
52
+ }, [])
53
+ const takeOpenAfterSwitch = useCallback((context: string) => {
54
+ const target = openAfterSwitch.current
55
+ openAfterSwitch.current = null
56
+ return target && target.context === context ? target.resource : null
57
+ }, [])
58
+
43
59
  return (
44
- <ContextSwitchContext.Provider value={{ isSwitching, targetContext, progressMessage, startSwitch, updateProgress, endSwitch }}>
60
+ <ContextSwitchContext.Provider value={{ isSwitching, targetContext, progressMessage, startSwitch, updateProgress, endSwitch, setOpenAfterSwitch, takeOpenAfterSwitch }}>
45
61
  {children}
46
62
  </ContextSwitchContext.Provider>
47
63
  )