@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.
Files changed (83) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +91 -328
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.jobset.test.ts +18 -0
  6. package/src/api/client.resource-identity.test.ts +44 -0
  7. package/src/api/client.rightsizing.test.ts +7 -3
  8. package/src/api/client.ts +233 -78
  9. package/src/api/preferences.test.ts +54 -0
  10. package/src/api/preferences.ts +38 -0
  11. package/src/components/CloudFunnelButton.test.tsx +155 -0
  12. package/src/components/CloudFunnelButton.tsx +14 -8
  13. package/src/components/ContextSwitcher.tsx +13 -132
  14. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  15. package/src/components/applications/ApplicationsView.tsx +5 -3
  16. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  17. package/src/components/diagnose/AgentControls.tsx +26 -3
  18. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  19. package/src/components/diagnose/agentCatalog.ts +6 -0
  20. package/src/components/diagnose/launch.test.ts +23 -0
  21. package/src/components/diagnose/launch.ts +4 -0
  22. package/src/components/diagnose/parts.test.tsx +18 -0
  23. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  24. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  25. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  26. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  27. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  28. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  29. package/src/components/execution/BatchExecutionView.tsx +324 -65
  30. package/src/components/execution/JobSetAdmission.test.tsx +42 -0
  31. package/src/components/execution/JobSetAdmission.tsx +17 -0
  32. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  33. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  34. package/src/components/execution/batch-run-actions.test.ts +1 -0
  35. package/src/components/execution/batch-timeline.test.ts +3 -0
  36. package/src/components/execution/execution-definition.test.ts +15 -1
  37. package/src/components/execution/execution-definition.ts +4 -0
  38. package/src/components/execution/member-collection.render.test.tsx +142 -0
  39. package/src/components/gitops/GitOpsView.tsx +33 -2
  40. package/src/components/gitops/destination-toast.test.ts +33 -0
  41. package/src/components/gitops/destination-toast.ts +46 -0
  42. package/src/components/gitops/useDestinationCluster.ts +30 -0
  43. package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
  44. package/src/components/home/MCPSetupDialog.tsx +10 -0
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  46. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  47. package/src/components/nav/PrimaryNavRail.tsx +2 -5
  48. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  49. package/src/components/resource/PVCUsageBar.tsx +62 -16
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  51. package/src/components/resources/ResourcesView.tsx +3 -3
  52. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  53. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  54. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  55. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  56. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  57. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  58. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  59. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  60. package/src/components/rightsizing/copy.test.ts +7 -0
  61. package/src/components/rightsizing/model.test.ts +2 -1
  62. package/src/components/rightsizing/notices.test.tsx +4 -3
  63. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  64. package/src/components/settings/SettingsDialog.tsx +157 -42
  65. package/src/components/settings/settings-state.test.ts +18 -0
  66. package/src/components/settings/settings-state.ts +18 -0
  67. package/src/components/timeline/TimelineView.tsx +9 -3
  68. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  69. package/src/components/ui/command-items.ts +0 -3
  70. package/src/components/useContextSwitchFlow.tsx +147 -0
  71. package/src/components/workload/WorkloadView.test.ts +17 -1
  72. package/src/components/workload/WorkloadView.tsx +36 -18
  73. package/src/context/ContextSwitchContext.tsx +18 -2
  74. package/src/context/NavCustomization.tsx +4 -58
  75. package/src/context/ThemeContext.tsx +3 -11
  76. package/src/hooks/useClusterLoadState.ts +2 -2
  77. package/src/hooks/useFavorites.ts +3 -5
  78. package/src/utils/auditBadges.ts +1 -1
  79. package/src/utils/navigation.test.ts +18 -1
  80. package/src/utils/navigation.ts +12 -5
  81. package/src/utils/topology-selection.test.ts +56 -0
  82. package/src/utils/topology-selection.ts +9 -9
  83. 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={lane} freeTier={connectInfo.data?.freeTier} />
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, useState, forwardRef } from 'react'
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, useSwitchContext, useClusterInfo, fetchSessionCounts, type SessionCounts } from '../api/client'
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 switchContext = useSwitchContext()
44
- const { startSwitch, endSwitch } = useContextSwitch()
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 performSwitch = async (parsed: ParsedContext) => {
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 || parsed.context.isCurrent || switchContext.isPending) return
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={switchContext.isPending}
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
- switchContext.isError ? (
214
- <span className="text-xs text-red-400">{switchContext.error?.message}</span>
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
- {showConfirm && sessionCounts && pendingSwitch && (
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[] {