@skyhook-io/radar-app 1.16.1 → 1.16.3

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 (73) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +51 -9
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +61 -49
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.ts +26 -0
  8. package/src/api/httpErrors.ts +12 -0
  9. package/src/api/timelineSource.ts +1 -1
  10. package/src/components/ConnectionErrorView.test.tsx +2 -0
  11. package/src/components/ConnectionErrorView.tsx +2 -1
  12. package/src/components/NoClusterAccessBanner.tsx +68 -0
  13. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  14. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  15. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  16. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  17. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  18. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  19. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  20. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  21. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  22. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  23. package/src/components/cnpg/CNPGView.tsx +156 -0
  24. package/src/components/cnpg/paths.ts +13 -0
  25. package/src/components/cnpg/routes.test.ts +60 -0
  26. package/src/components/cnpg/routes.ts +112 -0
  27. package/src/components/cnpg/shared.tsx +290 -0
  28. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  29. package/src/components/diagnose/AgentControls.tsx +1 -1
  30. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  31. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  32. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  33. package/src/components/diagnose/Home.tsx +1 -1
  34. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  35. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  36. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  37. package/src/components/diagnose/InvestigationView.tsx +199 -65
  38. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  39. package/src/components/diagnose/investigationState.test.ts +83 -1
  40. package/src/components/diagnose/investigationState.ts +43 -0
  41. package/src/components/diagnose/launch.ts +2 -2
  42. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  43. package/src/components/dock/BottomDock.tsx +1 -1
  44. package/src/components/dock/DockContext.tsx +27 -2
  45. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  46. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  47. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  48. package/src/components/helm/ChartBrowser.tsx +6 -7
  49. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  50. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  51. package/src/components/helm/InstallWizard.tsx +5 -2
  52. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  53. package/src/components/helm/ValuesViewer.tsx +1 -1
  54. package/src/components/logs/LogsViewer.tsx +3 -0
  55. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  56. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  57. package/src/components/resources/ResourcesView.tsx +6 -38
  58. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  59. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  60. package/src/components/timeline/TimelineList.test.ts +47 -29
  61. package/src/components/timeline/TimelineList.tsx +57 -11
  62. package/src/components/timeline/TimelineView.tsx +9 -2
  63. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  64. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  65. package/src/components/whats-new/WhatsNew.tsx +146 -86
  66. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  67. package/src/components/whats-new/releaseNotes.ts +156 -25
  68. package/src/components/workload/WorkloadView.tsx +30 -4
  69. package/src/context/DiagnoseCustomization.tsx +13 -3
  70. package/src/contexts/CapabilitiesContext.tsx +9 -1
  71. package/src/hooks/useResourceCounts.ts +49 -0
  72. package/src/index.ts +4 -2
  73. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -43,12 +43,12 @@ export function buildLaunchCommand(
43
43
  ): string | null {
44
44
  if (!run.sessionId || run.status === "stale") return null;
45
45
 
46
- // OpenCode terminal handoff needs a persistent MCP configuration outside the run workspace.
46
+ // OpenCode terminal handoff needs a persistent MCP configuration outside the run's working directory.
47
47
  if (run.agent === "opencode") return null;
48
48
 
49
49
  if (run.agent === "cursor-agent") {
50
50
  // Cursor's --resume is workspace-scoped, and Radar runs each investigation in a
51
- // throwaway per-run workspace the user doesn't have — no command can reattach
51
+ // throwaway per-run working directory the user doesn't have — no command can reattach
52
52
  // both that session and Radar's MCP in the user's own terminal. So there's no
53
53
  // hand-off for Cursor; the in-panel investigation is self-contained.
54
54
  return null;
@@ -84,7 +84,7 @@ export function useDisclosureReveal<T extends HTMLElement>() {
84
84
  );
85
85
  // This component lives in a bounded Diagnose surface. Never fall back to
86
86
  // scrolling the document (or an overflow-hidden ancestor), which can move
87
- // the entire workspace and expose blank space below it.
87
+ // the entire investigation surface and expose blank space below it.
88
88
  if (!scroller) return;
89
89
  // Expansion is delayed until Collapse has settled. If the reader scrolls
90
90
  // during that interval, their newer intent wins over the automatic reveal.
@@ -53,7 +53,7 @@ function renderTabContent(tab: DockTab, isActive: boolean) {
53
53
 
54
54
  if (tab.type === 'local-terminal') {
55
55
  return (
56
- <LocalTerminalTab isActive={isActive} initialCommand={tab.initialCommand} />
56
+ <LocalTerminalTab tabId={tab.id} title={tab.title} intendedContext={tab.localTerminalContext} isActive={isActive} initialCommand={tab.initialCommand} />
57
57
  )
58
58
  }
59
59
 
@@ -1,4 +1,8 @@
1
- // Re-exported from @skyhook-io/k8s-ui — source of truth is radar/packages/k8s-ui/src/components/dock/DockContext.tsx
1
+ import { useCallback, useLayoutEffect, useRef } from 'react'
2
+ import { useDock } from '@skyhook-io/k8s-ui'
3
+ import { useConnection } from '../../context/ConnectionContext'
4
+ import { useToast } from '../ui/Toast'
5
+
2
6
  export {
3
7
  DockProvider,
4
8
  useDock,
@@ -7,6 +11,27 @@ export {
7
11
  useOpenLogs,
8
12
  useOpenWorkloadLogs,
9
13
  useOpenNodeTerminal,
10
- useOpenLocalTerminal,
11
14
  } from '@skyhook-io/k8s-ui'
12
15
  export type { DockTab, DockTabType, DockContextValue } from '@skyhook-io/k8s-ui'
16
+
17
+ export function useOpenLocalTerminal() {
18
+ const { addTab } = useDock()
19
+ const { connection } = useConnection()
20
+ const { showToast } = useToast()
21
+ const contextRef = useRef({ context: connection.context, state: connection.state })
22
+ useLayoutEffect(() => { contextRef.current = { context: connection.context, state: connection.state } }, [connection.context, connection.state])
23
+
24
+ return useCallback((opts?: { initialCommand?: string; title?: string }) => {
25
+ const { context, state } = contextRef.current
26
+ if (!context && state !== 'disconnected') {
27
+ showToast('Waiting for a context. Try opening the terminal again.', { type: 'info' })
28
+ return
29
+ }
30
+ addTab({
31
+ type: 'local-terminal',
32
+ title: opts?.title || 'Terminal',
33
+ initialCommand: opts?.initialCommand,
34
+ localTerminalContext: context,
35
+ })
36
+ }, [addTab, showToast])
37
+ }
@@ -0,0 +1,210 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRequire } from 'node:module'
4
+ import { resolve } from 'node:path'
5
+ import { createRoot, type Root } from 'react-dom/client'
6
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
8
+ import { DockProvider, useDock, useOpenLocalTerminal as useSharedOpenLocalTerminal } from '../../../../packages/k8s-ui/src/components/dock/DockContext'
9
+ import { useOpenLocalTerminal } from './DockContext'
10
+ import { LocalTerminalTab } from './LocalTerminalTab'
11
+ import { ToastProvider } from '../../../../packages/k8s-ui/src/components/ui/Toast'
12
+
13
+ const state = vi.hoisted(() => ({ context: 'A', status: 'connected', mountTabs: true, open: null as null | (() => void) }))
14
+ vi.mock('../../context/ConnectionContext', () => ({
15
+ useConnection: () => ({ connection: { context: state.context, state: state.status } }),
16
+ }))
17
+ vi.mock('@skyhook-io/k8s-ui', async () => ({
18
+ ...await import('../../../../packages/k8s-ui/src/components/dock/DockContext'),
19
+ ...await import('../../../../packages/k8s-ui/src/components/dock/LocalTerminalTab'),
20
+ ...await import('../../../../packages/k8s-ui/src/components/ui/ClusterName'),
21
+ ...await import('../../../../packages/k8s-ui/src/components/ui/Tooltip'),
22
+ ...await import('../../../../packages/k8s-ui/src/components/ui/Toast'),
23
+ ...await import('../../../../packages/k8s-ui/src/utils/context-name'),
24
+ }))
25
+ vi.mock('../../../../packages/k8s-ui/src/components/dock/terminalClipboard', () => ({ setupTerminalClipboard: () => () => {}, copyTerminalSelection: vi.fn() }))
26
+ vi.mock('../../../../packages/k8s-ui/src/components/dock/TerminalClipboardToolbar', () => ({ TerminalClipboardToolbar: () => null }))
27
+ vi.mock('../../../../packages/k8s-ui/src/components/dock/useMultilinePasteConfirm', () => ({ useMultilinePasteConfirm: () => ({ confirmPaste: vi.fn(), pasteDialog: null }) }))
28
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
29
+ const { Terminal } = createRequire(resolve(__dirname, '../../../../packages/k8s-ui/package.json'))('@xterm/xterm')
30
+
31
+ class Socket {
32
+ static OPEN = 1
33
+ static instances: Socket[] = []
34
+ readyState = 0
35
+ onopen?: () => void
36
+ onclose?: () => void
37
+ onerror?: () => void
38
+ onmessage?: (event: { data: string }) => void
39
+ send = vi.fn()
40
+ constructor(public url: string) { Socket.instances.push(this) }
41
+ open() { this.readyState = 1; this.onopen?.() }
42
+ close() { this.readyState = 3; this.onclose?.() }
43
+ session(context: string) {
44
+ this.onmessage?.({ data: JSON.stringify({ type: 'session', context, kubeconfigIsolated: true }) })
45
+ }
46
+ }
47
+ let root: Root
48
+ let element: HTMLDivElement
49
+ let client: QueryClient
50
+ function Harness() {
51
+ const { tabs } = useDock()
52
+ const open = useOpenLocalTerminal()
53
+ const sharedOpen = useSharedOpenLocalTerminal()
54
+ state.open ??= () => open({ title: 'Auth', initialCommand: 'auth-marker' })
55
+ return <>
56
+ <button onClick={() => open({ title: 'Auth', initialCommand: 'auth-marker' })}>Open</button>
57
+ <button onClick={() => sharedOpen()}>Open shared</button>
58
+ {state.mountTabs && tabs.map(tab => <section key={tab.id}>
59
+ <h2>{tab.title}</h2>
60
+ <LocalTerminalTab tabId={tab.id} title={tab.title} intendedContext={tab.localTerminalContext} initialCommand={tab.initialCommand} />
61
+ </section>)}
62
+ </>
63
+ }
64
+ async function render() {
65
+ await act(async () => root.render(<QueryClientProvider client={client}><ToastProvider><DockProvider><Harness /></DockProvider></ToastProvider></QueryClientProvider>))
66
+ }
67
+ async function click(text: string, section: ParentNode = element) {
68
+ await act(async () => [...section.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === text)!.click())
69
+ }
70
+ beforeEach(() => {
71
+ vi.spyOn(Terminal.prototype, 'open').mockImplementation(() => {})
72
+ state.context = 'A'
73
+ state.status = 'connected'
74
+ state.mountTabs = true
75
+ state.open = null
76
+ Socket.instances = []
77
+ vi.useFakeTimers()
78
+ vi.stubGlobal('WebSocket', Socket)
79
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
80
+ vi.stubGlobal('requestAnimationFrame', () => 0)
81
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
82
+ element = document.createElement('div')
83
+ document.body.appendChild(element)
84
+ root = createRoot(element)
85
+ })
86
+ afterEach(async () => {
87
+ await act(async () => root.unmount())
88
+ client.clear()
89
+ element.remove()
90
+ vi.unstubAllGlobals()
91
+ vi.restoreAllMocks()
92
+ vi.useRealTimers()
93
+ })
94
+
95
+ it('captures A at the action before mounting, and keeps first delivery pending while B is selected', async () => {
96
+ state.mountTabs = false
97
+ await render()
98
+ await click('Open')
99
+ state.context = 'B'
100
+ state.mountTabs = true
101
+ await render()
102
+ expect(Socket.instances).toHaveLength(0)
103
+ expect(element.textContent).toContain('Requested:A')
104
+ expect(element.querySelector('button[aria-label="New terminal for B"]')).not.toBeNull()
105
+ expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Reconnect')?.disabled).toBe(true)
106
+ expect(element.textContent).toContain('Different context')
107
+ state.context = 'A'
108
+ await render()
109
+ await click('Reconnect')
110
+ expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('A')
111
+ await act(async () => { Socket.instances[0].open(); Socket.instances[0].session('A'); vi.advanceTimersByTime(300) })
112
+ expect(Socket.instances[0].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toEqual([[JSON.stringify({ type: 'input', data: 'auth-marker\n' })]])
113
+ })
114
+
115
+ it('preserves A through a refused reconnect, creates B independently, and reconnects A without replay', async () => {
116
+ await render()
117
+ await click('Open')
118
+ await act(async () => { Socket.instances[0].open(); Socket.instances[0].session('A'); vi.advanceTimersByTime(300) })
119
+ state.context = 'B'
120
+ await render()
121
+ expect(Socket.instances[0].readyState).toBe(1)
122
+ await act(async () => Socket.instances[0].close())
123
+ await click('Reconnect')
124
+ expect(Socket.instances).toHaveLength(1)
125
+ expect(element.querySelector('h2')?.textContent).toBe('Auth · A')
126
+ await click('New terminal')
127
+ expect(new URL(Socket.instances[1].url).searchParams.get('expectedContext')).toBe('B')
128
+ await act(async () => { Socket.instances[1].open(); Socket.instances[1].session('B') })
129
+ expect([...element.querySelectorAll('h2')].map(e => e.textContent)).toEqual(['Auth · A', 'Terminal · B'])
130
+ state.context = 'A'
131
+ await render()
132
+ await click('Reconnect', element.querySelector('section')!)
133
+ await act(async () => { Socket.instances[2].open(); Socket.instances[2].session('A'); vi.advanceTimersByTime(300) })
134
+ expect(new URL(Socket.instances[2].url).searchParams.get('expectedContext')).toBe('A')
135
+ expect(Socket.instances.flatMap(s => s.send.mock.calls).filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
136
+ })
137
+
138
+ it('refreshes connection state after handshake failure and can retry without consuming the command', async () => {
139
+ const refresh = vi.spyOn(client, 'invalidateQueries')
140
+ await render()
141
+ await click('Open')
142
+ await act(async () => { Socket.instances[0].onerror?.(); Socket.instances[0].close() })
143
+ expect(refresh).toHaveBeenCalledWith({ queryKey: ['connection-status'] })
144
+ state.context = 'B'
145
+ await render()
146
+ expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Retry')?.disabled).toBe(true)
147
+ await click('Retry')
148
+ expect(Socket.instances).toHaveLength(1)
149
+ state.context = 'A'
150
+ await render()
151
+ await click('Retry')
152
+ await act(async () => { Socket.instances[1].open(); Socket.instances[1].session('A'); vi.advanceTimersByTime(300) })
153
+ expect(Socket.instances[1].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
154
+ })
155
+
156
+ it('explains unknown-context refusal and lets an earlier callback read the latest committed context', async () => {
157
+ state.context = ''
158
+ state.status = 'connecting'
159
+ await render()
160
+ await click('Open')
161
+ expect(element.querySelector('section')).toBeNull()
162
+ expect(element.textContent).toContain('Waiting for a context. Try opening the terminal again.')
163
+ expect(Socket.instances).toHaveLength(0)
164
+ state.context = 'A & B/qualified'
165
+ await render()
166
+ expect(Socket.instances).toHaveLength(0)
167
+ await act(async () => state.open!())
168
+ expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('A & B/qualified')
169
+ })
170
+
171
+ it('gives a shared-hook tab without an intent an explicit state and a fresh context-aware action', async () => {
172
+ await render()
173
+ await click('Open shared')
174
+ expect(element.textContent).toContain('No context selected')
175
+ expect(Socket.instances).toHaveLength(0)
176
+ expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Reconnect')?.disabled).toBe(true)
177
+ await click('New terminal')
178
+ expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('A')
179
+ })
180
+
181
+ it('preserves the no-kubeconfig recovery shell with an explicit empty intent, then blocks reconnect under A', async () => {
182
+ state.context = ''
183
+ state.status = 'disconnected'
184
+ await render()
185
+ await click('Open')
186
+ const query = new URL(Socket.instances[0].url).searchParams
187
+ expect(query.has('expectedContext')).toBe(true)
188
+ expect(query.get('expectedContext')).toBe('')
189
+ await act(async () => { Socket.instances[0].open(); Socket.instances[0].onmessage?.({ data: JSON.stringify({ type: 'session', context: '', kubeconfigIsolated: false }) }); vi.advanceTimersByTime(300) })
190
+ expect(element.textContent).toContain('Context not confirmed')
191
+ expect(Socket.instances[0].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
192
+ state.context = 'A'
193
+ state.status = 'connected'
194
+ await render()
195
+ expect(Socket.instances[0].readyState).toBe(1)
196
+ await act(async () => Socket.instances[0].close())
197
+ expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Reconnect')?.disabled).toBe(true)
198
+ await click('New terminal')
199
+ expect(new URL(Socket.instances[1].url).searchParams.get('expectedContext')).toBe('A')
200
+ })
201
+
202
+ it('opens a recovery shell for the known context when startup reports a config error', async () => {
203
+ state.context = 'production@secondary'
204
+ state.status = 'disconnected'
205
+ await render()
206
+ await click('Open')
207
+ expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('production@secondary')
208
+ await act(async () => { Socket.instances[0].open(); Socket.instances[0].session('production@secondary'); vi.advanceTimersByTime(300) })
209
+ expect(Socket.instances[0].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
210
+ })
@@ -0,0 +1,122 @@
1
+ // @vitest-environment jsdom
2
+ import { act, useEffect } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+ import { DockProvider, useDock } from '../../../../packages/k8s-ui/src/components/dock/DockContext'
6
+ import type { LocalTerminalTabProps } from '../../../../packages/k8s-ui/src/components/dock/LocalTerminalTab'
7
+ import { LocalTerminalTab } from './LocalTerminalTab'
8
+ import { ToastProvider } from '../../../../packages/k8s-ui/src/components/ui/Toast'
9
+
10
+ const state = vi.hoisted(() => ({
11
+ context: 'staging',
12
+ firstContext: 'production',
13
+ props: new Map<string, LocalTerminalTabProps>(),
14
+ }))
15
+ vi.mock('../../context/ConnectionContext', () => ({
16
+ useConnection: () => ({ connection: { context: state.context } }),
17
+ }))
18
+ vi.mock('@tanstack/react-query', () => ({
19
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
20
+ }))
21
+ vi.mock('@skyhook-io/k8s-ui', async () => {
22
+ const dock = await import('../../../../packages/k8s-ui/src/components/dock/DockContext')
23
+ const { ClusterName } = await import('../../../../packages/k8s-ui/src/components/ui/ClusterName')
24
+ const { Tooltip } = await import('../../../../packages/k8s-ui/src/components/ui/Tooltip')
25
+ const { parseContextName } = await import('../../../../packages/k8s-ui/src/utils/context-name')
26
+ const { useToast } = await import('../../../../packages/k8s-ui/src/components/ui/Toast')
27
+ return {
28
+ ...dock, ClusterName, Tooltip, parseContextName, useToast,
29
+ LocalTerminalTab: (props: LocalTerminalTabProps) => {
30
+ state.props.set(props.initialCommand!, props)
31
+ return <div data-testid="terminal-toolbar">{props.toolbarExtra}</div>
32
+ },
33
+ }
34
+ })
35
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
36
+
37
+ let root: Root
38
+ let element: HTMLDivElement
39
+ function Harness() {
40
+ const { tabs, addTab } = useDock()
41
+ useEffect(() => {
42
+ addTab({ type: 'local-terminal', title: 'Terminal', initialCommand: 'first', localTerminalContext: state.firstContext })
43
+ addTab({ type: 'local-terminal', title: 'Auth', initialCommand: 'second', localTerminalContext: 'staging' })
44
+ }, [addTab])
45
+ return <>{tabs.map(tab => (
46
+ <section key={tab.id}>
47
+ <h2 data-tooltip={tab.titleTooltip}>{tab.title}</h2>
48
+ <LocalTerminalTab tabId={tab.id} title={tab.title} intendedContext={tab.localTerminalContext!} initialCommand={tab.initialCommand} />
49
+ </section>
50
+ ))}</>
51
+ }
52
+ async function render() {
53
+ await act(async () => root.render(<ToastProvider><DockProvider><Harness /></DockProvider></ToastProvider>))
54
+ }
55
+ beforeEach(() => {
56
+ state.context = 'staging'
57
+ state.firstContext = 'production'
58
+ state.props.clear()
59
+ element = document.createElement('div')
60
+ document.body.appendChild(element)
61
+ root = createRoot(element)
62
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
63
+ })
64
+ afterEach(async () => {
65
+ await act(async () => root.unmount())
66
+ element.remove()
67
+ vi.unstubAllGlobals()
68
+ })
69
+
70
+ it('waits for server metadata, labels each tab independently, and notices a UI switch', async () => {
71
+ await render()
72
+ expect(element.textContent).toContain('Requested:production')
73
+ await act(async () => {
74
+ state.props.get('first')!.onSessionInfo!({ context: 'production', kubeconfigIsolated: true })
75
+ state.props.get('second')!.onSessionInfo!({ context: 'staging', kubeconfigIsolated: true })
76
+ })
77
+ expect([...element.querySelectorAll('h2')].map(el => el.textContent)).toEqual(['Terminal · production', 'Auth · staging'])
78
+ expect(element.querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('This terminal is for production')
79
+ expect(element.querySelector('[role="status"]')?.closest('[data-testid="terminal-toolbar"]')).not.toBeNull()
80
+ state.context = 'production'
81
+ await render()
82
+ const sections = element.querySelectorAll('section')
83
+ expect(sections[0].querySelector('[role="status"]')).toBeNull()
84
+ expect(sections[0].textContent).toContain('Opened for:production')
85
+ expect(sections[1].querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('This terminal is for staging')
86
+ await act(async () => sections[1].querySelector<HTMLButtonElement>('button')!.click())
87
+ expect([...element.querySelectorAll('h2')].map(el => el.textContent)).toEqual(['Terminal · production', 'Auth · staging', 'Terminal'])
88
+ })
89
+
90
+ it('marks fallback sessions unconfirmed and replaces labels for a newly connected shell', async () => {
91
+ await render()
92
+ await act(async () => state.props.get('second')!.onSessionInfo!({ context: 'staging', kubeconfigIsolated: true }))
93
+ await act(async () => state.props.get('second')!.onSessionInfo!({ context: '', kubeconfigIsolated: false }))
94
+ const section = element.querySelectorAll('section')[1]
95
+ expect(section.textContent).toContain('Auth · context not confirmed')
96
+ expect(section.textContent).toContain('Context not confirmed')
97
+ expect(section.querySelector('[role="status"]')).toBeNull()
98
+ state.context = 'production'
99
+ await render()
100
+ expect(section.querySelector('[role="status"]')?.textContent).toContain('Requested context differs')
101
+ expect(section.querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('Requested for staging; kubeconfig not confirmed')
102
+ expect(section.querySelector('[role="status"]')?.getAttribute('aria-label')).not.toContain('This terminal is for staging')
103
+ await act(async () => state.props.get('second')!.onSessionInfo!(null))
104
+ expect(section.querySelector('h2')?.textContent).toBe('Auth · context not confirmed')
105
+ await act(async () => state.props.get('second')!.onSessionInfo!({ context: 'staging', kubeconfigIsolated: true }))
106
+ expect(section.querySelector('h2')?.textContent).toBe('Auth · staging')
107
+ })
108
+
109
+ it('compares full contexts even when both have the same short cluster name', async () => {
110
+ state.context = 'gke_project-b_us-east1_production'
111
+ state.firstContext = 'gke_project-a_us-east1_production'
112
+ await render()
113
+ await act(async () => state.props.get('first')!.onSessionInfo!({ context: 'gke_project-a_us-east1_production', kubeconfigIsolated: true }))
114
+ expect(element.querySelector('h2')?.textContent).toBe('Terminal · production')
115
+ expect(element.querySelector('h2')?.getAttribute('data-tooltip')).toBe('gke_project-a_us-east1_production')
116
+ const message = element.querySelector('[role="status"]')?.getAttribute('aria-label')
117
+ expect(message).toContain('gke_project-a_us-east1_production')
118
+ expect(message).toContain('gke_project-b_us-east1_production')
119
+ state.context = ''
120
+ await render()
121
+ expect(element.querySelector('[role="status"]')).toBeNull()
122
+ })
@@ -1,22 +1,97 @@
1
- import { LocalTerminalTab as SharedLocalTerminalTab } from '@skyhook-io/k8s-ui'
1
+ import { useCallback, useRef, useState } from 'react'
2
+ import { AlertTriangle } from 'lucide-react'
3
+ import { useQueryClient } from '@tanstack/react-query'
4
+ import { ClusterName, LocalTerminalTab as SharedLocalTerminalTab, Tooltip, parseContextName, useDock, type LocalTerminalSessionInfo } from '@skyhook-io/k8s-ui'
2
5
  import { getWsUrl } from '../../api/config'
6
+ import { useConnection } from '../../context/ConnectionContext'
7
+ import { useOpenLocalTerminal } from './DockContext'
3
8
 
4
9
  interface LocalTerminalTabProps {
10
+ tabId: string
11
+ title: string
12
+ intendedContext?: string
5
13
  isActive?: boolean
6
14
  initialCommand?: string
7
15
  }
8
16
 
9
- export function LocalTerminalTab({ isActive, initialCommand }: LocalTerminalTabProps) {
10
- const createSession = () =>
11
- Promise.resolve({
12
- wsUrl: getWsUrl('/local-terminal'),
13
- })
17
+ export function LocalTerminalTab({ tabId, title, intendedContext, isActive, initialCommand }: LocalTerminalTabProps) {
18
+ const { connection } = useConnection()
19
+ const queryClient = useQueryClient()
20
+ const { setTabTitle } = useDock()
21
+ const openLocalTerminal = useOpenLocalTerminal()
22
+ const baseTitle = useRef(title)
23
+ const [sessionInfo, setSessionInfo] = useState<LocalTerminalSessionInfo | null>(null)
24
+ const handleSessionInfo = useCallback((info: LocalTerminalSessionInfo | null) => {
25
+ if (!info) return
26
+ setSessionInfo(info)
27
+ const suffix = info.kubeconfigIsolated ? parseContextName(info.context).clusterName : 'context not confirmed'
28
+ setTabTitle(tabId, `${baseTitle.current} · ${suffix}`, info.kubeconfigIsolated ? info.context : undefined)
29
+ }, [tabId, setTabTitle])
30
+ const unconfirmed = sessionInfo !== null && !sessionInfo.kubeconfigIsolated
31
+ const contextNotice = unconfirmed
32
+ ? `Requested for ${intendedContext || 'no active context'}; kubeconfig not confirmed.`
33
+ : `This terminal is for ${intendedContext || 'no active context'}.`
34
+ const context = sessionInfo?.kubeconfigIsolated ? sessionInfo.context : null
35
+ const mismatched = intendedContext !== undefined && !!connection.context && intendedContext !== connection.context
36
+ const createSession = async () => {
37
+ if (intendedContext === undefined) throw new Error('No context selected. Open a new terminal for the selected context.')
38
+ return { wsUrl: getWsUrl(`/local-terminal?expectedContext=${encodeURIComponent(intendedContext)}`) }
39
+ }
14
40
 
15
41
  return (
16
42
  <SharedLocalTerminalTab
17
43
  isActive={isActive}
18
44
  createSession={createSession}
19
45
  initialCommand={initialCommand}
46
+ canConnect={() => intendedContext !== undefined && connection.context === intendedContext && (!!intendedContext || connection.state === 'disconnected')}
47
+ onConnectionError={() => { void queryClient.invalidateQueries({ queryKey: ['connection-status'] }) }}
48
+ onSessionInfo={handleSessionInfo}
49
+ toolbarExtra={
50
+ <div className="flex items-center gap-2 min-w-0">
51
+ {context ? (
52
+ <Tooltip content={<>Radar supplied a temporary kubeconfig for <strong>{context}</strong>. Shell settings and commands can override it.</>} wrapperClassName="min-w-0">
53
+ <span className="flex items-center gap-1 min-w-0 text-xs text-theme-text-secondary">
54
+ <span className="shrink-0">Opened for:</span>
55
+ <ClusterName name={context} noBadge noTooltip />
56
+ </span>
57
+ </Tooltip>
58
+ ) : !sessionInfo && intendedContext ? (
59
+ <Tooltip content={<>A new shell has been requested for <strong>{intendedContext}</strong>.</>} wrapperClassName="min-w-0">
60
+ <span className="flex items-center gap-1 min-w-0 text-xs text-theme-text-secondary">
61
+ <span className="shrink-0">Requested:</span>
62
+ <ClusterName name={intendedContext} noBadge noTooltip />
63
+ </span>
64
+ </Tooltip>
65
+ ) : intendedContext === undefined ? (
66
+ <span className="text-xs text-warning-text whitespace-nowrap">No context selected</span>
67
+ ) : (
68
+ <Tooltip content={sessionInfo
69
+ ? 'Radar could not create a temporary kubeconfig. This shell uses the original or inherited kubeconfig.'
70
+ : 'The server has not reported this terminal’s kubeconfig.'}>
71
+ <span className="text-xs text-warning-text">Context not confirmed</span>
72
+ </Tooltip>
73
+ )}
74
+ {mismatched && (
75
+ <Tooltip content={<>Radar is showing <strong>{connection.context}</strong>. {contextNotice} Switch back to reconnect, or open a new terminal.</>} wrapperClassName="shrink-0">
76
+ <span
77
+ role="status"
78
+ aria-label={`Radar is showing ${connection.context}. ${contextNotice}`}
79
+ className="flex items-center gap-1 text-xs text-warning-text whitespace-nowrap"
80
+ >
81
+ <AlertTriangle className="w-3 h-3" />
82
+ {unconfirmed ? 'Requested context differs' : 'Different context'}
83
+ </span>
84
+ </Tooltip>
85
+ )}
86
+ {connection.context && (mismatched || intendedContext === undefined) && (
87
+ <Tooltip content={<>Open a new terminal for <strong>{connection.context}</strong>.</>} wrapperClassName="shrink-0">
88
+ <button className="btn-brand px-2 py-0.5 text-xs whitespace-nowrap" aria-label={`New terminal for ${connection.context}`} onClick={() => openLocalTerminal()}>
89
+ New terminal
90
+ </button>
91
+ </Tooltip>
92
+ )}
93
+ </div>
94
+ }
20
95
  />
21
96
  )
22
97
  }
@@ -3,7 +3,7 @@ import { Search, RefreshCw, Package, Database, AlertCircle, ExternalLink, Chevro
3
3
  import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
4
4
  import { clsx } from 'clsx'
5
5
  import { useHelmRepositories, useSearchCharts, useUpdateRepository, useUpdateRepositorySilent, useArtifactHubSearch, type ArtifactHubSortOption } from '../../api/client'
6
- import { useCanHelmWrite } from '../../contexts/CapabilitiesContext'
6
+ import { useCanHelmWrite, useIsAuthEnabled } from '../../contexts/CapabilitiesContext'
7
7
  import type { ChartInfo, HelmRepository, ArtifactHubChart, ChartSource } from '../../types'
8
8
  import { formatAge } from './helm-utils'
9
9
  import { SEVERITY_BADGE } from '../../utils/badge-colors'
@@ -25,12 +25,11 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
25
25
  const [showVerifiedOnly, setShowVerifiedOnly] = useState(false)
26
26
  const [artifactHubSort, setArtifactHubSort] = useState<ArtifactHubSortOption>('relevance')
27
27
 
28
- // Repo refresh is gated only by `requireHelmWrite` on the backend
29
- // (handleUpdateRepository deliberately skips requireCloudRole — it
30
- // mutates pod-local chart cache, not cluster state). So the frontend gate
31
- // here must NOT include the Cloud role check, or Cloud viewers with
32
- // rbac.helm=true would be blocked from a refresh the backend allows.
33
- const canHelmWrite = useCanHelmWrite()
28
+ // Refreshing repositories only touches Radar's own chart cache. With auth on,
29
+ // any signed-in user may do it; without auth it follows rbac.helm.
30
+ const helmWrite = useCanHelmWrite()
31
+ const authEnabled = useIsAuthEnabled()
32
+ const canHelmWrite = helmWrite || authEnabled
34
33
  const helmWriteReason = canHelmWrite ? '' : 'Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.'
35
34
 
36
35
  // Local repo hooks