@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.
- package/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +142 -334
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +267 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +113 -63
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +18 -0
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +165 -0
- package/src/components/execution/JobSetAdmission.tsx +26 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +27 -8
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/model.test.ts +2 -1
- package/src/components/rightsizing/notices.test.tsx +4 -3
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +168 -92
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +19 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -3
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +47 -19
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +34 -1
- package/src/utils/navigation.ts +28 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/utils/version.ts +11 -0
- 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
|
-
|
|
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
|
)
|