@skyhook-io/radar-app 1.14.5 → 1.14.6
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 +91 -328
- package/src/RadarApp.tsx +3 -8
- 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 +233 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +14 -8
- package/src/components/ContextSwitcher.tsx +13 -132
- 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/diagnose/AgentControls.tsx +26 -3
- 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/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 +42 -0
- package/src/components/execution/JobSetAdmission.tsx +17 -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 +20 -11
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.tsx +2 -5
- 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/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/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/SettingsDialog.tsx +157 -42
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +18 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/command-items.ts +0 -3
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +36 -18
- 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/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +18 -1
- package/src/utils/navigation.ts +12 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/components/ui/CommandPalette.tsx +0 -261
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
describe('preference persistence by installation', () => {
|
|
4
|
+
beforeEach(() => { vi.resetModules() })
|
|
5
|
+
afterEach(() => { vi.unstubAllGlobals() })
|
|
6
|
+
|
|
7
|
+
it('keeps shared OSS theme and pins in the browser without writing the server', async () => {
|
|
8
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ preferenceStorage: 'browser' })))
|
|
9
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
10
|
+
const { persistPreferences } = await import('./preferences')
|
|
11
|
+
await persistPreferences({ theme: 'light' })
|
|
12
|
+
await persistPreferences({ pinnedKinds: [{ name: 'pods', kind: 'Pod', group: '' }] })
|
|
13
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
14
|
+
expect(fetchMock.mock.calls[0][0]).toBe('/api/settings')
|
|
15
|
+
expect(fetchMock.mock.calls[0][1].method).toBeUndefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('preserves local and Cloud persistence through configured API helpers', async () => {
|
|
19
|
+
const fetchMock = vi.fn().mockImplementation(async () => new Response('{}'))
|
|
20
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
21
|
+
const config = await import('./config')
|
|
22
|
+
config.setApiBase('/c/example/api')
|
|
23
|
+
config.setAuthHeadersProvider(() => ({ Authorization: 'test-header' }))
|
|
24
|
+
config.setCredentialsMode('include')
|
|
25
|
+
const { persistPreferences } = await import('./preferences')
|
|
26
|
+
await persistPreferences({ theme: 'light' })
|
|
27
|
+
expect(fetchMock).toHaveBeenLastCalledWith('/c/example/api/settings', expect.objectContaining({
|
|
28
|
+
method: 'PUT', credentials: 'include',
|
|
29
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'test-header' },
|
|
30
|
+
body: JSON.stringify({ theme: 'light' }),
|
|
31
|
+
}))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('never assumes permission to write when the settings read fails', async () => {
|
|
35
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response('denied', { status: 403 }))
|
|
36
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
37
|
+
const { persistPreferences } = await import('./preferences')
|
|
38
|
+
await expect(persistPreferences({ theme: 'light' })).rejects.toThrow('403')
|
|
39
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not send a pending change to another instance after navigation', async () => {
|
|
43
|
+
let finish!: (response: Response) => void
|
|
44
|
+
const fetchMock = vi.fn().mockReturnValue(new Promise<Response>((resolve) => { finish = resolve }))
|
|
45
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
46
|
+
const config = await import('./config')
|
|
47
|
+
const { persistPreferences } = await import('./preferences')
|
|
48
|
+
const saving = persistPreferences({ theme: 'light' })
|
|
49
|
+
config.setApiBase('/c/other/api')
|
|
50
|
+
finish(new Response('{}'))
|
|
51
|
+
await saving
|
|
52
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode } from './config'
|
|
2
|
+
import type { PinnedKind } from '../hooks/useFavorites'
|
|
3
|
+
|
|
4
|
+
interface Preferences {
|
|
5
|
+
theme?: 'light' | 'dark'
|
|
6
|
+
pinnedKinds?: PinnedKind[]
|
|
7
|
+
preferenceStorage?: 'browser'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let request: { base: string; promise: Promise<Preferences> } | undefined
|
|
11
|
+
|
|
12
|
+
export function loadPreferences(): Promise<Preferences> {
|
|
13
|
+
const base = getApiBase()
|
|
14
|
+
if (!request || request.base !== base) {
|
|
15
|
+
const promise = fetch(apiUrl('/settings'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
|
|
16
|
+
.then((res) => {
|
|
17
|
+
if (!res.ok) throw new Error(`Failed to load preferences: HTTP ${res.status}`)
|
|
18
|
+
return res.json() as Promise<Preferences>
|
|
19
|
+
})
|
|
20
|
+
request = { base, promise }
|
|
21
|
+
promise.catch(() => { if (request?.promise === promise) request = undefined })
|
|
22
|
+
}
|
|
23
|
+
return request.promise
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function persistPreferences(patch: Pick<Preferences, 'theme' | 'pinnedKinds'>): Promise<void> {
|
|
27
|
+
const base = getApiBase()
|
|
28
|
+
const preferences = await loadPreferences()
|
|
29
|
+
if (base !== getApiBase() || preferences.preferenceStorage === 'browser') return
|
|
30
|
+
const res = await fetch(apiUrl('/settings'), {
|
|
31
|
+
method: 'PUT',
|
|
32
|
+
credentials: getCredentialsMode(),
|
|
33
|
+
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
34
|
+
body: JSON.stringify(patch),
|
|
35
|
+
})
|
|
36
|
+
if (!res.ok) throw new Error(`Failed to save preferences: HTTP ${res.status}`)
|
|
37
|
+
if (request?.base === base) request = undefined
|
|
38
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, type ReactNode } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import type { ConnectionStateType } from '../context/ConnectionContext'
|
|
7
|
+
import { CloudFunnelButton } from './CloudFunnelButton'
|
|
8
|
+
|
|
9
|
+
const connection = vi.hoisted(() => ({ state: 'connecting' as ConnectionStateType }))
|
|
10
|
+
vi.mock('../context/ConnectionContext', () => ({ useConnection: () => ({ connection }) }))
|
|
11
|
+
vi.mock('./ui/Tooltip', () => ({ Tooltip: ({ children }: { children: ReactNode }) => children }))
|
|
12
|
+
vi.mock('./ui/Toast', () => ({ showApiError: vi.fn() }))
|
|
13
|
+
vi.mock('./CloudConnectFlow', () => ({ CloudConnectFlow: () => <div>Install flow</div> }))
|
|
14
|
+
|
|
15
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
16
|
+
let root: Root
|
|
17
|
+
let client: QueryClient
|
|
18
|
+
let lane: 'driver' | 'wizard'
|
|
19
|
+
let discovery: { connected: { namespace: string; deployment: string; clusterUrl: string }[] }
|
|
20
|
+
let discoveryStatus: number
|
|
21
|
+
let flowState: 'idle' | 'awaiting_approval'
|
|
22
|
+
const requests: string[] = []
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
connection.state = 'connecting'
|
|
26
|
+
lane = 'driver'
|
|
27
|
+
discovery = { connected: [] }
|
|
28
|
+
discoveryStatus = 200
|
|
29
|
+
flowState = 'idle'
|
|
30
|
+
requests.length = 0
|
|
31
|
+
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
32
|
+
const element = document.createElement('div')
|
|
33
|
+
document.body.appendChild(element)
|
|
34
|
+
root = createRoot(element)
|
|
35
|
+
vi.stubGlobal('fetch', vi.fn(async (input: string) => {
|
|
36
|
+
const url = new URL(input, 'http://localhost')
|
|
37
|
+
requests.push(url.pathname + url.search)
|
|
38
|
+
const bodies: Record<string, unknown> = {
|
|
39
|
+
'/api/capabilities': { cloudConnect: { lane, appUrl: 'https://cloud.example', apiUrl: 'https://cloud.example' }, deployment: { mode: 'local' } },
|
|
40
|
+
'/api/cluster-info': { context: 'test-cluster' },
|
|
41
|
+
'/api/cloud/install/status': { state: flowState },
|
|
42
|
+
'/api/cloud/install/discover': discoveryStatus === 200 ? discovery : { error: 'Discovery failed' },
|
|
43
|
+
'/api/cloud/install/prepare': { error: 'Not connected to cluster' },
|
|
44
|
+
'/api/connect/info': {},
|
|
45
|
+
}
|
|
46
|
+
if (!(url.pathname in bodies)) throw new Error(`Unexpected request: ${input}`)
|
|
47
|
+
const status = url.pathname.endsWith('/prepare') ? 503 : url.pathname.endsWith('/discover') ? discoveryStatus : 200
|
|
48
|
+
return new Response(JSON.stringify(bodies[url.pathname]), { status })
|
|
49
|
+
}))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterEach(async () => {
|
|
53
|
+
await act(async () => root.unmount())
|
|
54
|
+
client.clear()
|
|
55
|
+
document.body.replaceChildren()
|
|
56
|
+
vi.unstubAllGlobals()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
async function render() {
|
|
60
|
+
await act(async () => {
|
|
61
|
+
root.render(<QueryClientProvider client={client}><CloudFunnelButton /></QueryClientProvider>)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function until(assertion: () => void) {
|
|
66
|
+
await vi.waitFor(async () => {
|
|
67
|
+
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
|
|
68
|
+
assertion()
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function button(text: string) {
|
|
73
|
+
return Array.from(document.querySelectorAll('button')).find(element => element.textContent === text)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function openDialog() {
|
|
77
|
+
await render()
|
|
78
|
+
await until(() => expect(document.querySelector('[aria-label="Radar Cloud"]')).not.toBeNull())
|
|
79
|
+
await act(async () => { (document.querySelector('[aria-label="Radar Cloud"]') as HTMLButtonElement).click() })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('Cloud dialog connection availability', () => {
|
|
83
|
+
it.each(['connecting', 'disconnected'] as const)('offers Cloud directly while %s without inspecting a cluster', async state => {
|
|
84
|
+
connection.state = state
|
|
85
|
+
await openDialog()
|
|
86
|
+
await until(() => expect(requests).toContain('/api/connect/info?lane=wizard&mode=local'))
|
|
87
|
+
const link = Array.from(document.querySelectorAll('a')).find(element => element.textContent === 'Continue in Radar Cloud')!
|
|
88
|
+
expect(link.href).toBe('https://cloud.example/signup?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal&utm_content=driver-footer-browser-link')
|
|
89
|
+
expect(button('Connect this cluster…')).toBeUndefined()
|
|
90
|
+
expect(button('Try again')).toBeUndefined()
|
|
91
|
+
expect(requests).not.toContain('/api/cloud/install/discover')
|
|
92
|
+
expect(requests).not.toContain('/api/cloud/install/prepare')
|
|
93
|
+
await act(async () => { button('How it works and what it costs')!.click() })
|
|
94
|
+
expect(document.body.textContent).toContain('You approve the connection before anything is installed.')
|
|
95
|
+
expect(document.body.textContent).not.toContain('Setup runs here in the app')
|
|
96
|
+
expect(document.body.textContent).not.toContain('Nothing installs on click')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('restores cluster discovery and Connect in the open dialog when the connection recovers', async () => {
|
|
100
|
+
await openDialog()
|
|
101
|
+
connection.state = 'connected'
|
|
102
|
+
await render()
|
|
103
|
+
await until(() => expect(button('Connect this cluster…')?.disabled).toBe(false))
|
|
104
|
+
expect(requests).toContain('/api/cloud/install/discover')
|
|
105
|
+
expect(requests).toContain('/api/connect/info?lane=driver&mode=local')
|
|
106
|
+
connection.state = 'disconnected'
|
|
107
|
+
await render()
|
|
108
|
+
expect(button('Connect this cluster…')).toBeUndefined()
|
|
109
|
+
expect(document.body.textContent).toContain('Continue in Radar Cloud')
|
|
110
|
+
connection.state = 'connected'
|
|
111
|
+
await render()
|
|
112
|
+
await until(() => expect(button('Connect this cluster…')?.disabled).toBe(false))
|
|
113
|
+
expect(requests.filter(path => path === '/api/cloud/install/discover')).toHaveLength(2)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('keeps the existing already-connected destination after discovery', async () => {
|
|
117
|
+
connection.state = 'connected'
|
|
118
|
+
discovery.connected = [{ namespace: 'radar', deployment: 'radar', clusterUrl: 'https://cloud.example/clusters/one' }]
|
|
119
|
+
await openDialog()
|
|
120
|
+
await until(() => expect(document.body.textContent).toContain('This cluster is already connected to Radar Cloud.'))
|
|
121
|
+
expect(document.querySelector('a[href="https://cloud.example/clusters/one"]')?.textContent).toBe('Open in Radar Cloud')
|
|
122
|
+
expect(button('Connect this cluster…')).toBeUndefined()
|
|
123
|
+
connection.state = 'disconnected'
|
|
124
|
+
await render()
|
|
125
|
+
expect(document.body.textContent).not.toContain('This cluster is already connected to Radar Cloud.')
|
|
126
|
+
expect(document.body.textContent).toContain('Continue in Radar Cloud')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('still allows inspection after discovery fails and preserves 503 handoff attribution', async () => {
|
|
130
|
+
connection.state = 'connected'
|
|
131
|
+
discoveryStatus = 500
|
|
132
|
+
await openDialog()
|
|
133
|
+
await until(() => expect(button('Connect this cluster…')?.disabled).toBe(false))
|
|
134
|
+
await act(async () => { button('Connect this cluster…')!.click() })
|
|
135
|
+
await until(() => expect(button('Try again')).toBeDefined())
|
|
136
|
+
expect(document.querySelector('a[href*="driver-footer-browser-link"]')?.getAttribute('href')).toContain('radar_outcome=radar_not_connected_to_cluster')
|
|
137
|
+
expect(document.body.textContent).toContain('Not connected to cluster')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('keeps wizard-only deployments on their existing signup path', async () => {
|
|
141
|
+
lane = 'wizard'
|
|
142
|
+
await openDialog()
|
|
143
|
+
expect(document.querySelector('a[href*="wizard-signup-button"]')?.textContent).toBe('Try Cloud free')
|
|
144
|
+
expect(document.body.textContent).not.toContain('Continue in Radar Cloud')
|
|
145
|
+
expect(requests).not.toContain('/api/cloud/install/discover')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('keeps an active server-owned install visible even while disconnected', async () => {
|
|
149
|
+
connection.state = 'disconnected'
|
|
150
|
+
flowState = 'awaiting_approval'
|
|
151
|
+
await openDialog()
|
|
152
|
+
await until(() => expect(document.body.textContent).toContain('Install flow'))
|
|
153
|
+
expect(document.body.textContent).not.toContain('Continue in Radar Cloud')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
signupUrlFor as buildSignupUrl,
|
|
16
16
|
} from './cloudConnectHandoff'
|
|
17
17
|
import { showApiError } from './ui/Toast'
|
|
18
|
+
import { useConnection } from '../context/ConnectionContext'
|
|
18
19
|
import {
|
|
19
20
|
ApiError,
|
|
20
21
|
cloudInstallActive,
|
|
@@ -113,6 +114,8 @@ export function CloudFunnelButton() {
|
|
|
113
114
|
const capabilities = useCapabilities()
|
|
114
115
|
const clusterInfo = useClusterInfo()
|
|
115
116
|
const lane = capabilities.data?.cloudConnect?.lane ?? 'wizard'
|
|
117
|
+
const clusterConnected = useConnection().connection.state === 'connected'
|
|
118
|
+
const pitchLane = lane === 'driver' && !clusterConnected ? 'wizard' : lane
|
|
116
119
|
const appUrl = capabilities.data?.cloudConnect?.appUrl || FALLBACK_APP_URL
|
|
117
120
|
// utm_content names the link that was clicked. It travels only in the link
|
|
118
121
|
// the user opens; Radar sends nothing on its own. Only the blocked card may
|
|
@@ -124,7 +127,7 @@ export function CloudFunnelButton() {
|
|
|
124
127
|
// learns that someone opened it, which is congruent with what the dialog is
|
|
125
128
|
// for; it must not learn that Radar is merely running.
|
|
126
129
|
const connectInfo = useCloudConnectInfo(capabilities.data?.cloudConnect?.apiUrl, open, {
|
|
127
|
-
lane,
|
|
130
|
+
lane: pitchLane,
|
|
128
131
|
mode: capabilities.data?.deployment?.mode,
|
|
129
132
|
})
|
|
130
133
|
|
|
@@ -138,14 +141,14 @@ export function CloudFunnelButton() {
|
|
|
138
141
|
// carries Cloud settings — installed by a colleague after a handoff, or from
|
|
139
142
|
// another machine. The plan would refuse to install over it; better to say
|
|
140
143
|
// "already connected" and point at it than to offer a click that ends blocked.
|
|
141
|
-
const discovered = useCloudInstallDiscover(open && lane === 'driver', clusterInfo.data?.context)
|
|
144
|
+
const discovered = useCloudInstallDiscover(open && lane === 'driver' && clusterConnected, clusterInfo.data?.context)
|
|
142
145
|
// The server ranks these: one it can link to first. A failed lookup is
|
|
143
146
|
// not a verdict — the CTA returns and the plan does its own inspection —
|
|
144
147
|
// and neither is the previous opening's answer: this component outlives
|
|
145
148
|
// the dialog, so a reopen refetches over cached data, and the gate must
|
|
146
149
|
// hold for that refetch too (isFetching, not isPending) while a refetch
|
|
147
150
|
// that fails must not keep showing what it found last time.
|
|
148
|
-
const alreadyConnected = discovered.isError ? undefined : discovered.data?.connected[0]
|
|
151
|
+
const alreadyConnected = !clusterConnected || discovered.isError ? undefined : discovered.data?.connected[0]
|
|
149
152
|
const discoverPending = lane === 'driver' && discovered.isFetching
|
|
150
153
|
|
|
151
154
|
// The flow is server-owned: polling here both drives the live progress view
|
|
@@ -332,10 +335,11 @@ export function CloudFunnelButton() {
|
|
|
332
335
|
) : (
|
|
333
336
|
<>
|
|
334
337
|
<div className="min-h-0 overflow-y-auto">
|
|
335
|
-
<PitchBody lane={
|
|
338
|
+
<PitchBody lane={pitchLane} freeTier={connectInfo.data?.freeTier} />
|
|
336
339
|
</div>
|
|
337
340
|
<ModalFooter
|
|
338
341
|
lane={lane}
|
|
342
|
+
clusterConnected={clusterConnected}
|
|
339
343
|
signupUrl={signupUrl}
|
|
340
344
|
// One link name whether or not an attempt preceded the click; the
|
|
341
345
|
// outcome, when present, is what says an attempt happened.
|
|
@@ -398,6 +402,7 @@ function Eyebrow() {
|
|
|
398
402
|
|
|
399
403
|
function ModalFooter({
|
|
400
404
|
lane,
|
|
405
|
+
clusterConnected,
|
|
401
406
|
signupUrl,
|
|
402
407
|
driverBrowserUrl,
|
|
403
408
|
prepareFailed,
|
|
@@ -415,6 +420,7 @@ function ModalFooter({
|
|
|
415
420
|
onLater,
|
|
416
421
|
}: {
|
|
417
422
|
lane: 'driver' | 'wizard'
|
|
423
|
+
clusterConnected: boolean
|
|
418
424
|
signupUrl: string
|
|
419
425
|
// Same destination as signupUrl, distinct utm_content, and the outcome of
|
|
420
426
|
// an in-app attempt when one preceded this render.
|
|
@@ -534,7 +540,7 @@ function ModalFooter({
|
|
|
534
540
|
{alreadyConnected.clusterUrl ? 'Open in Radar Cloud' : 'Open Radar Cloud'}
|
|
535
541
|
</a>
|
|
536
542
|
)
|
|
537
|
-
) : lane === 'driver' ? (
|
|
543
|
+
) : lane === 'driver' && clusterConnected ? (
|
|
538
544
|
<>
|
|
539
545
|
<button
|
|
540
546
|
onClick={onConnect}
|
|
@@ -559,14 +565,14 @@ function ModalFooter({
|
|
|
559
565
|
</>
|
|
560
566
|
) : cliOnly ? null : (
|
|
561
567
|
<a
|
|
562
|
-
href={self?.wizardUrl || signupUrl}
|
|
568
|
+
href={lane === 'driver' ? driverBrowserUrl : self?.wizardUrl || signupUrl}
|
|
563
569
|
aria-disabled={selfPending}
|
|
564
570
|
target="_blank"
|
|
565
571
|
rel="noopener noreferrer"
|
|
566
572
|
onClick={(e) => { if (selfPending) e.preventDefault() }}
|
|
567
573
|
className={`px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[13.5px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all ${selfPending ? 'opacity-60 pointer-events-none' : ''}`}
|
|
568
574
|
>
|
|
569
|
-
{self?.ownership === 'helm' || gitops ? 'Connect this cluster' : 'Try Cloud free'}
|
|
575
|
+
{lane === 'driver' ? 'Continue in Radar Cloud' : self?.ownership === 'helm' || gitops ? 'Connect this cluster' : 'Try Cloud free'}
|
|
570
576
|
</a>
|
|
571
577
|
)}
|
|
572
578
|
<button onClick={onLater} className="ml-auto whitespace-nowrap text-[12px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
|
|
@@ -584,7 +590,7 @@ function ModalFooter({
|
|
|
584
590
|
)}
|
|
585
591
|
{/* Mechanics, not marketing: a falsifiable claim the plan card then
|
|
586
592
|
fulfills. Sits next to the button whose click it de-risks. */}
|
|
587
|
-
{lane === 'driver' && !alreadyConnected && (
|
|
593
|
+
{lane === 'driver' && clusterConnected && !alreadyConnected && (
|
|
588
594
|
<p className="mt-2.5 text-[11px] leading-relaxed text-theme-text-tertiary">
|
|
589
595
|
Nothing installs on click. Radar inspects{' '}
|
|
590
596
|
{clusterName ? <span className="text-theme-text-secondary">{clusterName}</span> : 'the cluster'} and shows
|
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
import { useMemo,
|
|
2
|
-
import { AlertTriangle } from 'lucide-react'
|
|
1
|
+
import { useMemo, forwardRef } from 'react'
|
|
3
2
|
import {
|
|
4
3
|
ClusterSwitcher,
|
|
5
4
|
type ClusterSwitcherItem,
|
|
6
|
-
pluralize,
|
|
7
5
|
} from '@skyhook-io/k8s-ui'
|
|
8
|
-
import { useContexts,
|
|
9
|
-
import { useContextSwitch } from '../context/ContextSwitchContext'
|
|
10
|
-
import { useToast } from '../components/ui/Toast'
|
|
11
|
-
import { useDock } from '../components/dock'
|
|
6
|
+
import { useContexts, useClusterInfo, useCapabilities } from '../api/client'
|
|
12
7
|
import type { ContextInfo } from '../types'
|
|
8
|
+
import { useContextSwitchFlow } from './useContextSwitchFlow'
|
|
13
9
|
import { parseContextForSwitcher, visibleContextQualifier, type ParsedContextName } from '../utils/context-name'
|
|
14
10
|
|
|
15
11
|
interface ContextSwitcherProps {
|
|
@@ -28,22 +24,11 @@ interface ParsedContext extends ParsedContextName {
|
|
|
28
24
|
nameQualifier?: string
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
function shouldSuppressSwitchErrorToast(error: unknown): boolean {
|
|
32
|
-
const message = error instanceof Error ? error.message : ''
|
|
33
|
-
return message.includes('cluster connection failed:')
|
|
34
|
-
}
|
|
35
|
-
|
|
36
27
|
export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '', variant, label, triggerName }, ref) => {
|
|
37
|
-
const [showConfirm, setShowConfirm] = useState(false)
|
|
38
|
-
const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
|
|
39
|
-
const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
|
|
40
|
-
|
|
41
28
|
const { data: contexts, isLoading: contextsLoading } = useContexts()
|
|
42
29
|
const { data: clusterInfo } = useClusterInfo()
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const { showError } = useToast()
|
|
46
|
-
const { tabs } = useDock()
|
|
30
|
+
const { data: capabilities } = useCapabilities()
|
|
31
|
+
const switchFlow = useContextSwitchFlow()
|
|
47
32
|
|
|
48
33
|
// Parse contexts and decide whether to render group headers (multi-account only).
|
|
49
34
|
// hasMultipleSources gates the kubeconfig-source chip — only useful when 2+
|
|
@@ -101,69 +86,10 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
|
|
|
101
86
|
})
|
|
102
87
|
}, [parsedById, hasMultipleAccounts, hasMultipleSources])
|
|
103
88
|
|
|
104
|
-
const
|
|
105
|
-
startSwitch({
|
|
106
|
-
raw: parsed.raw,
|
|
107
|
-
provider: parsed.provider,
|
|
108
|
-
account: parsed.account,
|
|
109
|
-
region: parsed.region,
|
|
110
|
-
clusterName: parsed.clusterName,
|
|
111
|
-
})
|
|
112
|
-
try {
|
|
113
|
-
await switchContext.mutateAsync({ name: parsed.context.name })
|
|
114
|
-
} catch (error) {
|
|
115
|
-
console.error('Failed to switch context:', error)
|
|
116
|
-
endSwitch()
|
|
117
|
-
// Backend may not transition to StateDisconnected on client-side errors
|
|
118
|
-
// (network, timeout) — without this toast the user gets no feedback.
|
|
119
|
-
if (!shouldSuppressSwitchErrorToast(error)) {
|
|
120
|
-
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
121
|
-
showError('Failed to switch context', message)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const handleSelect = async (item: ClusterSwitcherItem) => {
|
|
89
|
+
const handleSelect = (item: ClusterSwitcherItem) => {
|
|
127
90
|
const parsed = parsedById.get(item.id)
|
|
128
|
-
if (!parsed
|
|
129
|
-
|
|
130
|
-
// Active sessions (port forwards from API + terminal tabs from dock) get
|
|
131
|
-
// a confirmation prompt — switching contexts kills both.
|
|
132
|
-
try {
|
|
133
|
-
const counts = await fetchSessionCounts()
|
|
134
|
-
const terminalTabs = tabs.filter(t => t.type === 'terminal').length
|
|
135
|
-
const total = counts.portForwards + terminalTabs
|
|
136
|
-
if (total > 0) {
|
|
137
|
-
setSessionCounts({ ...counts, execSessions: terminalTabs, total })
|
|
138
|
-
setPendingSwitch(parsed)
|
|
139
|
-
setShowConfirm(true)
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
} catch (error) {
|
|
143
|
-
// Session-counts is best-effort; failing it shouldn't block the user.
|
|
144
|
-
// But warn — if there ARE active sessions we couldn't see, the switch
|
|
145
|
-
// will silently kill them.
|
|
146
|
-
console.error('Failed to check sessions:', error)
|
|
147
|
-
showError(
|
|
148
|
-
'Could not check active sessions',
|
|
149
|
-
'Switching anyway. Any open port-forwards or terminals will be terminated.',
|
|
150
|
-
)
|
|
151
|
-
}
|
|
152
|
-
performSwitch(parsed)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const handleConfirmSwitch = () => {
|
|
156
|
-
setShowConfirm(false)
|
|
157
|
-
if (pendingSwitch) {
|
|
158
|
-
performSwitch(pendingSwitch)
|
|
159
|
-
setPendingSwitch(null)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const handleCancelSwitch = () => {
|
|
164
|
-
setShowConfirm(false)
|
|
165
|
-
setPendingSwitch(null)
|
|
166
|
-
setSessionCounts(null)
|
|
91
|
+
if (!parsed) return
|
|
92
|
+
switchFlow.requestSwitch(parsed.context)
|
|
167
93
|
}
|
|
168
94
|
|
|
169
95
|
// In-cluster mode renders a static badge instead of a switcher (only one
|
|
@@ -205,63 +131,18 @@ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcher
|
|
|
205
131
|
currentSourceLabel={currentSourceLabel}
|
|
206
132
|
items={items}
|
|
207
133
|
onSelect={handleSelect}
|
|
208
|
-
loading={
|
|
209
|
-
disabled={contextsLoading}
|
|
134
|
+
loading={switchFlow.isPending}
|
|
135
|
+
disabled={contextsLoading || !capabilities || capabilities.configManagement === 'operator'}
|
|
210
136
|
searchable={items.length > 1}
|
|
211
137
|
showGroupHeaders={hasMultipleAccounts}
|
|
212
138
|
errorSlot={
|
|
213
|
-
|
|
214
|
-
<span className="text-xs text-red-400">{
|
|
139
|
+
switchFlow.error ? (
|
|
140
|
+
<span className="text-xs text-red-400">{switchFlow.error.message}</span>
|
|
215
141
|
) : undefined
|
|
216
142
|
}
|
|
217
143
|
/>
|
|
218
144
|
|
|
219
|
-
{
|
|
220
|
-
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
|
|
221
|
-
<div className="bg-theme-surface border border-theme-border rounded-lg shadow-xl max-w-md mx-4 overflow-hidden">
|
|
222
|
-
<div className="px-4 py-3 border-b border-theme-border flex items-center gap-2">
|
|
223
|
-
<AlertTriangle className="w-5 h-5 text-amber-400" />
|
|
224
|
-
<span className="font-medium text-theme-text-primary">Active Sessions</span>
|
|
225
|
-
</div>
|
|
226
|
-
<div className="px-4 py-4">
|
|
227
|
-
<p className="text-sm text-theme-text-secondary mb-3">
|
|
228
|
-
Switching contexts will terminate active sessions:
|
|
229
|
-
</p>
|
|
230
|
-
<ul className="text-sm text-theme-text-primary space-y-1 mb-4">
|
|
231
|
-
{sessionCounts.portForwards > 0 && (
|
|
232
|
-
<li className="flex items-center gap-2">
|
|
233
|
-
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
|
234
|
-
{pluralize(sessionCounts.portForwards, 'port forward')}
|
|
235
|
-
</li>
|
|
236
|
-
)}
|
|
237
|
-
{sessionCounts.execSessions > 0 && (
|
|
238
|
-
<li className="flex items-center gap-2">
|
|
239
|
-
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
|
240
|
-
{pluralize(sessionCounts.execSessions, 'terminal session')}
|
|
241
|
-
</li>
|
|
242
|
-
)}
|
|
243
|
-
</ul>
|
|
244
|
-
<p className="text-xs text-theme-text-tertiary">
|
|
245
|
-
Switch to: <span className="text-theme-text-secondary">{pendingSwitch.clusterName}</span>
|
|
246
|
-
</p>
|
|
247
|
-
</div>
|
|
248
|
-
<div className="px-4 py-3 border-t border-theme-border flex justify-end gap-2">
|
|
249
|
-
<button
|
|
250
|
-
onClick={handleCancelSwitch}
|
|
251
|
-
className="px-3 py-1.5 text-sm rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary transition-colors"
|
|
252
|
-
>
|
|
253
|
-
Cancel
|
|
254
|
-
</button>
|
|
255
|
-
<button
|
|
256
|
-
onClick={handleConfirmSwitch}
|
|
257
|
-
className="px-3 py-1.5 text-sm rounded-md bg-amber-500 hover:bg-amber-600 text-white transition-colors"
|
|
258
|
-
>
|
|
259
|
-
Switch Anyway
|
|
260
|
-
</button>
|
|
261
|
-
</div>
|
|
262
|
-
</div>
|
|
263
|
-
</div>
|
|
264
|
-
)}
|
|
145
|
+
{switchFlow.confirmDialog}
|
|
265
146
|
</>
|
|
266
147
|
)
|
|
267
148
|
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { AppWorkload, Issue } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { appIssuesForWorkloads } from './ApplicationsView'
|
|
5
|
+
|
|
6
|
+
function workload(group: string): AppWorkload {
|
|
7
|
+
return { kind: 'Job', group, namespace: 'ml', name: 'train', health: 'healthy', ready: 1, desired: 1, restarts: 0 }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function issue(id: string, group?: string): Issue {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
severity: 'warning',
|
|
14
|
+
source: 'condition',
|
|
15
|
+
category: 'job_failed',
|
|
16
|
+
category_group: 'runtime',
|
|
17
|
+
grouping_scope: 'workload',
|
|
18
|
+
kind: 'Job',
|
|
19
|
+
group,
|
|
20
|
+
namespace: 'ml',
|
|
21
|
+
name: 'train',
|
|
22
|
+
reason: id,
|
|
23
|
+
message: id,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('appIssuesForWorkloads identity', () => {
|
|
28
|
+
it('does not share an issue between same-named core and Volcano Jobs', () => {
|
|
29
|
+
const issues = [issue('core', 'batch'), issue('volcano', 'batch.volcano.sh')]
|
|
30
|
+
|
|
31
|
+
expect(appIssuesForWorkloads(issues, [workload('batch')]).map(item => item.id)).toEqual(['core'])
|
|
32
|
+
expect(appIssuesForWorkloads(issues, [workload('batch.volcano.sh')]).map(item => item.id)).toEqual(['volcano'])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('treats an omitted built-in group as the canonical built-in identity', () => {
|
|
36
|
+
expect(appIssuesForWorkloads([issue('legacy')], [workload('batch')]).map(item => item.id)).toEqual(['legacy'])
|
|
37
|
+
expect(appIssuesForWorkloads([issue('legacy')], [workload('batch.volcano.sh')])).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
memberRef,
|
|
24
24
|
subjectRef,
|
|
25
25
|
compareIssueSortAnchors,
|
|
26
|
+
canonicalResourceGroup,
|
|
26
27
|
type AppRow,
|
|
27
28
|
type AppWorkload,
|
|
28
29
|
type AppIdentityInstance,
|
|
@@ -490,6 +491,7 @@ function AppDetailRoute({
|
|
|
490
491
|
source.kind,
|
|
491
492
|
source.namespace,
|
|
492
493
|
source.name,
|
|
494
|
+
source.group,
|
|
493
495
|
);
|
|
494
496
|
if (path) navigate(path);
|
|
495
497
|
return;
|
|
@@ -899,7 +901,7 @@ function compareAppOverviewIssues(a: Issue, b: Issue): number {
|
|
|
899
901
|
return a.id.localeCompare(b.id);
|
|
900
902
|
}
|
|
901
903
|
|
|
902
|
-
function appIssuesForWorkloads(
|
|
904
|
+
export function appIssuesForWorkloads(
|
|
903
905
|
issues: Issue[],
|
|
904
906
|
workloads: AppWorkload[],
|
|
905
907
|
): Issue[] {
|
|
@@ -918,11 +920,11 @@ function appIssuesForWorkloads(
|
|
|
918
920
|
}
|
|
919
921
|
|
|
920
922
|
function workloadIssueKey(workload: AppWorkload): string {
|
|
921
|
-
return `${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`;
|
|
923
|
+
return `${canonicalResourceGroup(workload.kind, workload.group) ?? "?"}|${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`;
|
|
922
924
|
}
|
|
923
925
|
|
|
924
926
|
function issueRefKey(ref: IssueResourceRef): string {
|
|
925
|
-
return `${ref.kind.toLowerCase()}|${ref.namespace ?? ""}|${ref.name}`;
|
|
927
|
+
return `${canonicalResourceGroup(ref.kind, ref.group) ?? "?"}|${ref.kind.toLowerCase()}|${ref.namespace ?? ""}|${ref.name}`;
|
|
926
928
|
}
|
|
927
929
|
|
|
928
930
|
function issueRefs(issue: Issue): IssueResourceRef[] {
|