@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.
- package/package.json +5 -5
- package/src/App.tsx +51 -9
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +61 -49
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- package/src/components/helm/ChartBrowser.tsx +6 -7
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +57 -11
- package/src/components/timeline/TimelineView.tsx +9 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +156 -25
- package/src/components/workload/WorkloadView.tsx +30 -4
- package/src/context/DiagnoseCustomization.tsx +13 -3
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/index.ts +4 -2
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|