@skyhook-io/k8s-ui 1.16.0 → 1.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  Shared, source-distributed Kubernetes UI components used by Radar.
4
4
 
5
+ ## Local terminal metadata
6
+
7
+ `LocalTerminalTab` accepts optional `onSessionInfo` and `toolbarExtra` props.
8
+ `onSessionInfo` reports the server's `session` frame (`context` and
9
+ `kubeconfigIsolated`), and receives `null` when a connection attempt starts.
10
+ This describes the supplied kubeconfig, not a shell's live command target.
11
+ `toolbarExtra` lets the host render that information in the terminal toolbar.
12
+ Hosts can update a dock tab's label and optional full-name tooltip with
13
+ `useDock().setTabTitle(id, title, titleTooltip)`.
14
+ The optional `DockTab.localTerminalContext` field lets a host retain the full
15
+ context selected when a local terminal was requested. An empty string records
16
+ no active context; an omitted field records no intent. The shared terminal and
17
+ open hook do not interpret it; Radar's host wrapper checks it on open/reconnect.
18
+ `canConnect` is a predicate evaluated during rendering to disable Reconnect/Retry,
19
+ and before an attempt to preserve the existing terminal when refused.
20
+ `onConnectionError` lets a host refresh connection state after a failed WebSocket
21
+ handshake.
22
+
23
+ `initialCommand` is sent once per mounted terminal. Reconnect does not repeat a
24
+ command that was already sent; if the connection closes before delivery, the
25
+ command remains pending for the next connection.
26
+
5
27
  ## YAML editor bundling
6
28
 
7
29
  `YamlEditor` and `YamlDiffEditor` bundle Monaco, its editor worker, and the YAML language worker into the consuming application. They make no runtime CDN or internet requests, so they work in air-gapped environments.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.16.0",
3
+ "version": "1.16.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -4,6 +4,7 @@ import { X, ChevronDown, ChevronUp, Terminal, FileText, Trash2, Layers, Maximize
4
4
  import { clsx } from 'clsx'
5
5
  import { useDock, DockTab } from './DockContext'
6
6
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
7
+ import { Tooltip } from '../ui/Tooltip'
7
8
 
8
9
  const MIN_HEIGHT = 200
9
10
  const MAX_HEIGHT_RATIO = 0.7
@@ -224,7 +225,13 @@ function TabButton({
224
225
  onClick={onSelect}
225
226
  >
226
227
  <Icon className="w-3.5 h-3.5" />
227
- <span className="truncate max-w-[120px]">{tab.title}</span>
228
+ {tab.titleTooltip ? (
229
+ <Tooltip content={tab.titleTooltip} wrapperClassName="min-w-0">
230
+ <span className="block truncate max-w-[120px]">{tab.title}</span>
231
+ </Tooltip>
232
+ ) : (
233
+ <span className="truncate max-w-[120px]">{tab.title}</span>
234
+ )}
228
235
  <button
229
236
  onClick={(e) => {
230
237
  e.stopPropagation()
@@ -6,6 +6,7 @@ export interface DockTab {
6
6
  id: string
7
7
  type: DockTabType
8
8
  title: string
9
+ titleTooltip?: string
9
10
  // Common app context (optional — app-specific, used by tab content components)
10
11
  orgId?: string
11
12
  clusterId?: string
@@ -22,6 +23,7 @@ export interface DockTab {
22
23
  nodeName?: string
23
24
  // Local terminal props
24
25
  initialCommand?: string
26
+ localTerminalContext?: string
25
27
  }
26
28
 
27
29
  export interface DockContextValue {
@@ -33,6 +35,7 @@ export interface DockContextValue {
33
35
  isMaximized: boolean
34
36
  isResizing: boolean
35
37
  addTab: (tab: Omit<DockTab, 'id'>) => string
38
+ setTabTitle: (id: string, title: string, titleTooltip?: string) => void
36
39
  removeTab: (id: string) => void
37
40
  setActiveTab: (id: string) => void
38
41
  toggleExpanded: () => void
@@ -118,6 +121,10 @@ export function DockProvider({ children }: { children: ReactNode }) {
118
121
  })
119
122
  }, [activeTabId])
120
123
 
124
+ const setTabTitle = useCallback((id: string, title: string, titleTooltip?: string) => {
125
+ setTabs(prev => prev.map(tab => tab.id === id ? { ...tab, title, titleTooltip } : tab))
126
+ }, [])
127
+
121
128
  const setActiveTab = useCallback((id: string) => {
122
129
  setActiveTabId(id)
123
130
  }, [])
@@ -142,6 +149,7 @@ export function DockProvider({ children }: { children: ReactNode }) {
142
149
  isMaximized,
143
150
  isResizing,
144
151
  addTab,
152
+ setTabTitle,
145
153
  removeTab,
146
154
  setActiveTab,
147
155
  toggleExpanded,
@@ -0,0 +1,189 @@
1
+ // @vitest-environment jsdom
2
+ import { act, StrictMode } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+ import { LocalTerminalTab, type LocalTerminalTabProps } from './LocalTerminalTab'
6
+ import { TerminalTab } from './TerminalTab'
7
+
8
+ vi.mock('@xterm/xterm', () => ({
9
+ Terminal: class {
10
+ rows = 24
11
+ cols = 80
12
+ loadAddon() {}
13
+ open() {}
14
+ resize() {}
15
+ focus() {}
16
+ dispose() {}
17
+ write() {}
18
+ onData() {}
19
+ },
20
+ }))
21
+ vi.mock('@xterm/addon-fit', () => ({ FitAddon: class { proposeDimensions() { return { rows: 24, cols: 80 } } } }))
22
+ vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: class {} }))
23
+ vi.mock('./terminalClipboard', () => ({ setupTerminalClipboard: () => () => {}, copyTerminalSelection: vi.fn() }))
24
+ vi.mock('./TerminalClipboardToolbar', () => ({ TerminalClipboardToolbar: () => null }))
25
+ vi.mock('./useMultilinePasteConfirm', () => ({ useMultilinePasteConfirm: () => ({ confirmPaste: vi.fn(), pasteDialog: null }) }))
26
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
27
+
28
+ class Socket {
29
+ static OPEN = 1
30
+ static instances: Socket[] = []
31
+ readyState = 1
32
+ onopen?: () => void
33
+ onclose?: () => void
34
+ onmessage?: (event: { data: string }) => void
35
+ send = vi.fn()
36
+ close() { this.readyState = 3; this.onclose?.() }
37
+ constructor() { Socket.instances.push(this) }
38
+ session(context: string) {
39
+ this.onmessage?.({ data: JSON.stringify({ type: 'session', context, kubeconfigIsolated: true }) })
40
+ }
41
+ }
42
+ let root: Root
43
+ let element: HTMLDivElement
44
+ beforeEach(() => {
45
+ Socket.instances = []
46
+ vi.stubGlobal('WebSocket', Socket)
47
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
48
+ vi.stubGlobal('requestAnimationFrame', () => 0)
49
+ element = document.createElement('div')
50
+ document.body.appendChild(element)
51
+ root = createRoot(element)
52
+ })
53
+ afterEach(async () => {
54
+ await act(async () => root.unmount())
55
+ element.remove()
56
+ vi.unstubAllGlobals()
57
+ vi.useRealTimers()
58
+ })
59
+ async function render(props: Partial<LocalTerminalTabProps> = {}, strict = false) {
60
+ await act(async () => {
61
+ const terminal = <LocalTerminalTab createSession={async () => ({ wsUrl: 'ws://localhost' })} {...props} />
62
+ root.render(strict ? <StrictMode>{terminal}</StrictMode> : terminal)
63
+ })
64
+ }
65
+
66
+ it('reports the current session, clears metadata on reconnect, and ignores old socket messages', async () => {
67
+ const firstCallback = vi.fn()
68
+ await render({ onSessionInfo: firstCallback })
69
+ expect(firstCallback).toHaveBeenCalledWith(null)
70
+ const old = Socket.instances[0]
71
+ await act(async () => { old.onopen?.(); old.session('production') })
72
+ expect(firstCallback).toHaveBeenLastCalledWith({ context: 'production', kubeconfigIsolated: true })
73
+ const currentCallback = vi.fn()
74
+ await render({ onSessionInfo: currentCallback })
75
+ await act(async () => old.close())
76
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
77
+ expect(currentCallback).toHaveBeenLastCalledWith(null)
78
+ const current = Socket.instances[1]
79
+ await act(async () => { current.onopen?.(); current.session('staging'); old.session('production') })
80
+ expect(currentCallback).toHaveBeenLastCalledWith({ context: 'staging', kubeconfigIsolated: true })
81
+ expect(currentCallback).toHaveBeenCalledTimes(2)
82
+ })
83
+
84
+ it('does not open an obsolete session when Strict Mode creations resolve out of order', async () => {
85
+ let resolveOld!: (value: { wsUrl: string }) => void
86
+ const pending = new Promise<{ wsUrl: string }>(resolve => { resolveOld = resolve })
87
+ const createSession = vi.fn().mockReturnValueOnce(pending).mockResolvedValueOnce({ wsUrl: 'ws://localhost/current' })
88
+ const onSessionInfo = vi.fn()
89
+ await render({ createSession, onSessionInfo }, true)
90
+ expect(createSession).toHaveBeenCalledTimes(2)
91
+ expect(Socket.instances).toHaveLength(1)
92
+ await act(async () => resolveOld({ wsUrl: 'ws://localhost/obsolete' }))
93
+ expect(Socket.instances).toHaveLength(1)
94
+ await act(async () => Socket.instances[0].session('current'))
95
+ expect(onSessionInfo).toHaveBeenLastCalledWith({ context: 'current', kubeconfigIsolated: true })
96
+ })
97
+
98
+ it('sends the initial command once and does not replay it on reconnect', async () => {
99
+ vi.useFakeTimers()
100
+ await render({ initialCommand: 'aws sso login' })
101
+ const first = Socket.instances[0]
102
+ await act(async () => first.onopen?.())
103
+ expect(first.send).not.toHaveBeenCalledWith(JSON.stringify({ type: 'input', data: 'aws sso login\n' }))
104
+ await act(async () => vi.advanceTimersByTime(300))
105
+ const inputs = () => Socket.instances.flatMap(socket => socket.send.mock.calls.map(([data]) => JSON.parse(data))).filter(message => message.type === 'input')
106
+ expect(inputs()).toEqual([{ type: 'input', data: 'aws sso login\n' }])
107
+
108
+ await act(async () => first.close())
109
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
110
+ await act(async () => Socket.instances[1].onopen?.())
111
+ await act(async () => vi.advanceTimersByTime(300))
112
+ expect(inputs()).toEqual([{ type: 'input', data: 'aws sso login\n' }])
113
+ })
114
+
115
+ it('keeps an unsent initial command available when the first connection closes before delivery', async () => {
116
+ vi.useFakeTimers()
117
+ await render({ initialCommand: 'gcloud auth login' })
118
+ const first = Socket.instances[0]
119
+ await act(async () => first.onopen?.())
120
+ await act(async () => vi.advanceTimersByTime(100))
121
+ await act(async () => first.close())
122
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
123
+ const second = Socket.instances[1]
124
+ await act(async () => second.onopen?.())
125
+ await act(async () => vi.advanceTimersByTime(200))
126
+ const input = JSON.stringify({ type: 'input', data: 'gcloud auth login\n' })
127
+ expect(first.send).not.toHaveBeenCalledWith(input)
128
+ expect(second.send).not.toHaveBeenCalledWith(input)
129
+ await act(async () => vi.advanceTimersByTime(100))
130
+ expect(second.send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toEqual([[input]])
131
+ })
132
+
133
+ it('sends the initial command independently in each terminal tab', async () => {
134
+ vi.useFakeTimers()
135
+ await act(async () => {
136
+ root.render(<>
137
+ <LocalTerminalTab createSession={async () => ({ wsUrl: 'ws://localhost/first' })} initialCommand="aws sso login" />
138
+ <LocalTerminalTab createSession={async () => ({ wsUrl: 'ws://localhost/second' })} initialCommand="aws sso login" />
139
+ </>)
140
+ })
141
+ expect(Socket.instances).toHaveLength(2)
142
+ await act(async () => Socket.instances.forEach(socket => socket.onopen?.()))
143
+ await act(async () => vi.advanceTimersByTime(300))
144
+ const input = JSON.stringify({ type: 'input', data: 'aws sso login\n' })
145
+ for (const socket of Socket.instances) {
146
+ expect(socket.send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toEqual([[input]])
147
+ }
148
+ })
149
+
150
+ it('keeps a blocked reconnect intact until the host permits it', async () => {
151
+ vi.useFakeTimers()
152
+ let allowed = true
153
+ const createSession = vi.fn(async () => ({ wsUrl: 'ws://localhost' }))
154
+ const onSessionInfo = vi.fn()
155
+ await render({ createSession, canConnect: () => allowed, initialCommand: 'auth-command', onSessionInfo })
156
+ await act(async () => Socket.instances[0].onopen?.())
157
+ await act(async () => vi.advanceTimersByTime(300))
158
+ await act(async () => Socket.instances[0].close())
159
+ const terminal = element.querySelector('div.absolute')
160
+ expect(terminal).not.toBeNull()
161
+ const metadataCalls = onSessionInfo.mock.calls.length
162
+ allowed = false
163
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
164
+ expect(createSession).toHaveBeenCalledTimes(1)
165
+ expect(onSessionInfo).toHaveBeenCalledTimes(metadataCalls)
166
+ expect(element.contains(terminal)).toBe(true)
167
+ allowed = true
168
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
169
+ await act(async () => Socket.instances[1].onopen?.())
170
+ await act(async () => vi.advanceTimersByTime(300))
171
+ expect(createSession).toHaveBeenCalledTimes(2)
172
+ expect(Socket.instances.flatMap(s => s.send.mock.calls).filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
173
+ })
174
+
175
+ it.each(['local', 'pod'])('allows Retry after a %s terminal creation error', async kind => {
176
+ const createSession = vi.fn()
177
+ .mockRejectedValueOnce(new Error('creation failed'))
178
+ .mockResolvedValueOnce({ wsUrl: 'ws://localhost' })
179
+ await act(async () => root.render(kind === 'local'
180
+ ? <LocalTerminalTab createSession={createSession} />
181
+ : <TerminalTab namespace="default" podName="pod" containerName="app" containers={['app']} createSession={createSession} />))
182
+ expect(element.textContent).toContain('creation failed')
183
+ const retry = [...element.querySelectorAll('button')].find(button => button.textContent?.trim() === 'Retry')!
184
+ await act(async () => retry.click())
185
+ expect(createSession).toHaveBeenCalledTimes(2)
186
+ expect(Socket.instances).toHaveLength(1)
187
+ await act(async () => Socket.instances[0].onopen?.())
188
+ expect(element.textContent).not.toContain('creation failed')
189
+ })
@@ -1,4 +1,4 @@
1
- import { useEffect, useLayoutEffect, useRef, useCallback, useState } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useCallback, useState, type ReactNode } from 'react'
2
2
  import { Terminal as XTerm } from '@xterm/xterm'
3
3
  import { FitAddon } from '@xterm/addon-fit'
4
4
  import { WebLinksAddon } from '@xterm/addon-web-links'
@@ -9,31 +9,52 @@ import { setupTerminalClipboard, copyTerminalSelection } from './terminalClipboa
9
9
  import { TerminalClipboardToolbar } from './TerminalClipboardToolbar'
10
10
  import { useMultilinePasteConfirm } from './useMultilinePasteConfirm'
11
11
 
12
+ export interface LocalTerminalSessionInfo {
13
+ context: string
14
+ kubeconfigIsolated: boolean
15
+ }
16
+
12
17
  export interface LocalTerminalTabProps {
13
18
  isActive?: boolean
14
19
  /** Returns the WebSocket URL for the local terminal session */
15
20
  createSession: () => Promise<{ wsUrl: string }>
16
- /** Command to auto-execute after the terminal connects */
21
+ /** Command to send once per mounted terminal after a connection opens */
17
22
  initialCommand?: string
23
+ canConnect?: () => boolean
24
+ onConnectionError?: () => void
25
+ onSessionInfo?: (info: LocalTerminalSessionInfo | null) => void
26
+ toolbarExtra?: ReactNode
18
27
  }
19
28
 
20
29
  export function LocalTerminalTab({
21
30
  isActive = true,
22
31
  createSession,
23
32
  initialCommand,
33
+ canConnect,
34
+ onConnectionError,
35
+ onSessionInfo,
36
+ toolbarExtra,
24
37
  }: LocalTerminalTabProps) {
25
38
  const terminalRef = useRef<HTMLDivElement>(null)
26
39
  const xtermRef = useRef<XTerm | null>(null)
27
40
  const fitAddonRef = useRef<FitAddon | null>(null)
28
41
  const wsRef = useRef<WebSocket | null>(null)
29
42
  const cleanupRef = useRef<(() => void) | undefined>(undefined)
30
- const cancelledRef = useRef(false)
43
+ const connectionAttemptRef = useRef(0)
44
+ const initialCommandSentRef = useRef(false)
31
45
  const createSessionRef = useRef(createSession)
46
+ const onSessionInfoRef = useRef(onSessionInfo)
47
+ const canConnectRef = useRef(canConnect)
48
+ const onConnectionErrorRef = useRef(onConnectionError)
32
49
  useLayoutEffect(() => { createSessionRef.current = createSession }, [createSession])
50
+ useLayoutEffect(() => { onSessionInfoRef.current = onSessionInfo }, [onSessionInfo])
51
+ useLayoutEffect(() => { canConnectRef.current = canConnect }, [canConnect])
52
+ useLayoutEffect(() => { onConnectionErrorRef.current = onConnectionError }, [onConnectionError])
33
53
  const [isConnected, setIsConnected] = useState(false)
34
54
  const [isConnecting, setIsConnecting] = useState(true)
35
55
  const [error, setError] = useState<string | null>(null)
36
56
  const [hasSelection, setHasSelection] = useState(false)
57
+ const connectAllowed = canConnect?.() !== false
37
58
  const { confirmPaste, pasteDialog } = useMultilinePasteConfirm()
38
59
 
39
60
  const handleCopy = useCallback(() => {
@@ -43,7 +64,13 @@ export function LocalTerminalTab({
43
64
  const connect = useCallback(() => {
44
65
  if (!terminalRef.current) return
45
66
 
46
- cancelledRef.current = false
67
+ if (canConnectRef.current && !canConnectRef.current()) {
68
+ setIsConnecting(false)
69
+ return
70
+ }
71
+
72
+ const attempt = ++connectionAttemptRef.current
73
+ onSessionInfoRef.current?.(null)
47
74
  setIsConnecting(true)
48
75
  setError(null)
49
76
 
@@ -145,34 +172,39 @@ export function LocalTerminalTab({
145
172
 
146
173
  createSessionRef.current()
147
174
  .then(({ wsUrl }) => {
148
- if (cancelledRef.current) return
175
+ if (attempt !== connectionAttemptRef.current) return
149
176
  const ws = new WebSocket(wsUrl)
150
177
  wsRef.current = ws
151
178
 
152
179
  ws.onopen = () => {
180
+ if (attempt !== connectionAttemptRef.current) return
153
181
  setIsConnected(true)
154
182
  setIsConnecting(false)
155
183
  doFit(ws)
156
184
  xterm.focus()
157
- if (initialCommand) {
185
+ if (initialCommand && !initialCommandSentRef.current) {
158
186
  // Small delay to let the shell prompt initialize
159
187
  setTimeout(() => {
160
- if (ws.readyState === WebSocket.OPEN) {
188
+ if (attempt === connectionAttemptRef.current && !initialCommandSentRef.current && ws.readyState === WebSocket.OPEN) {
161
189
  ws.send(JSON.stringify({ type: 'input', data: initialCommand + '\n' }))
190
+ initialCommandSentRef.current = true
162
191
  }
163
192
  }, 300)
164
193
  }
165
194
  }
166
195
 
167
196
  ws.onmessage = (event) => {
168
- let msg: Record<string, string> | null = null
197
+ if (attempt !== connectionAttemptRef.current) return
198
+ let msg: { type: 'output' | 'error' | 'exit'; data?: string } | ({ type: 'session' } & LocalTerminalSessionInfo)
169
199
  try {
170
- msg = JSON.parse(event.data as string) as Record<string, string>
200
+ msg = JSON.parse(event.data as string)
171
201
  } catch {
172
202
  xterm.write(event.data as string)
173
203
  return
174
204
  }
175
- if (msg.type === 'output' && msg.data) {
205
+ if (msg.type === 'session') {
206
+ onSessionInfoRef.current?.({ context: msg.context, kubeconfigIsolated: msg.kubeconfigIsolated })
207
+ } else if (msg.type === 'output' && msg.data) {
176
208
  xterm.write(msg.data)
177
209
  } else if (msg.type === 'exit') {
178
210
  xterm.write('\r\n\x1b[2m[Process exited]\x1b[0m\r\n')
@@ -183,12 +215,15 @@ export function LocalTerminalTab({
183
215
  }
184
216
 
185
217
  ws.onerror = () => {
218
+ if (attempt !== connectionAttemptRef.current) return
219
+ onConnectionErrorRef.current?.()
186
220
  setError((prev) => prev || 'Connection error')
187
221
  setIsConnected(false)
188
222
  setIsConnecting(false)
189
223
  }
190
224
 
191
225
  ws.onclose = () => {
226
+ if (attempt !== connectionAttemptRef.current) return
192
227
  setIsConnected(false)
193
228
  setIsConnecting(false)
194
229
  xterm.write('\r\n\x1b[31mConnection closed\x1b[0m\r\n')
@@ -201,6 +236,7 @@ export function LocalTerminalTab({
201
236
  })
202
237
  })
203
238
  .catch((err) => {
239
+ if (attempt !== connectionAttemptRef.current) return
204
240
  setError(err instanceof Error ? err.message : 'Failed to connect')
205
241
  setIsConnecting(false)
206
242
  })
@@ -209,7 +245,7 @@ export function LocalTerminalTab({
209
245
  useEffect(() => {
210
246
  connect()
211
247
  return () => {
212
- cancelledRef.current = true
248
+ connectionAttemptRef.current++
213
249
  cleanupRef.current?.()
214
250
  wsRef.current?.close()
215
251
  xtermRef.current?.dispose()
@@ -232,16 +268,18 @@ export function LocalTerminalTab({
232
268
  <span
233
269
  title={isConnected ? 'Connected' : isConnecting ? 'Connecting...' : 'Disconnected'}
234
270
  className={clsx(
235
- 'w-2 h-2 rounded-full',
271
+ 'w-2 h-2 shrink-0 rounded-full',
236
272
  isConnected ? 'bg-green-500' : isConnecting ? 'bg-yellow-500 animate-pulse' : 'bg-red-500'
237
273
  )}
238
274
  />
239
- <span className="text-xs text-theme-text-tertiary">Local Terminal</span>
275
+ <span className="shrink-0 text-xs text-theme-text-tertiary">Local Terminal</span>
276
+ {toolbarExtra}
240
277
 
241
278
  {!isConnected && !isConnecting && (
242
279
  <button
243
280
  onClick={connect}
244
- className="flex items-center gap-1 px-2 py-0.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
281
+ disabled={!connectAllowed}
282
+ className="shrink-0 whitespace-nowrap flex items-center gap-1 px-2 py-0.5 text-xs text-theme-text-tertiary enabled:hover:text-theme-text-primary enabled:hover:bg-theme-elevated disabled:opacity-50 disabled:cursor-not-allowed rounded"
245
283
  >
246
284
  <RefreshCw className="w-3 h-3" />
247
285
  Reconnect
@@ -252,20 +290,20 @@ export function LocalTerminalTab({
252
290
  </div>
253
291
 
254
292
  {/* Terminal or error */}
255
- {error ? (
293
+ <div ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
294
+ {error && (
256
295
  <div key="error" className="absolute top-8 left-0 right-0 bottom-0 flex flex-col items-center justify-center p-4 text-center bg-slate-900">
257
296
  <div className="text-red-400 mb-2 text-sm">Failed to connect</div>
258
297
  <div className="text-xs text-theme-text-disabled mb-3">{error}</div>
259
298
  <button
260
299
  onClick={connect}
261
- className="flex items-center gap-2 px-3 py-1.5 btn-brand text-xs rounded"
300
+ disabled={!connectAllowed}
301
+ className="flex items-center gap-2 px-3 py-1.5 btn-brand text-xs rounded disabled:opacity-50 disabled:cursor-not-allowed"
262
302
  >
263
303
  <RefreshCw className="w-3 h-3" />
264
304
  Retry
265
305
  </button>
266
306
  </div>
267
- ) : (
268
- <div key="terminal" ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
269
307
  )}
270
308
  {pasteDialog}
271
309
  </div>
@@ -298,8 +298,8 @@ export function TerminalTab({
298
298
  {!error && <TerminalClipboardToolbar hasSelection={hasSelection} onCopy={handleCopy} />}
299
299
  </div>
300
300
 
301
- {/* Terminal or error — key forces xterm canvas unmount/remount on toggle */}
302
- {error ? (
301
+ <div ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
302
+ {error && (
303
303
  <div key="error" className="absolute top-8 left-0 right-0 bottom-0 flex flex-col items-center justify-center p-4 text-center bg-slate-900">
304
304
  {errorType === 'shell_not_found' ? (
305
305
  <>
@@ -347,8 +347,6 @@ export function TerminalTab({
347
347
  </>
348
348
  )}
349
349
  </div>
350
- ) : (
351
- <div key="terminal" ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
352
350
  )}
353
351
  {pasteDialog}
354
352
  </div>
@@ -0,0 +1,77 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { LogCore } from './LogCore'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+
9
+ let root: Root
10
+ let element: HTMLDivElement
11
+ beforeEach(() => {
12
+ localStorage.clear()
13
+ element = document.createElement('div')
14
+ document.body.appendChild(element)
15
+ root = createRoot(element)
16
+ })
17
+ afterEach(async () => {
18
+ await act(async () => root.unmount())
19
+ element.remove()
20
+ })
21
+
22
+ const noop = () => {}
23
+ async function render(props: { forceDark?: boolean; defaultDark?: boolean }) {
24
+ await act(async () =>
25
+ root.render(
26
+ <LogCore
27
+ entries={[]}
28
+ isLoading={false}
29
+ isStreaming={false}
30
+ onStopStream={noop}
31
+ onRefresh={noop}
32
+ onDownload={noop}
33
+ {...props}
34
+ />,
35
+ ),
36
+ )
37
+ }
38
+ const toggle = () => element.querySelector<HTMLButtonElement>('button[aria-label^="Switch log viewer"]')
39
+
40
+ describe('LogCore theme', () => {
41
+ it('defaults to dark when no theme hint is given', async () => {
42
+ await render({})
43
+ expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to light mode')
44
+ })
45
+
46
+ it('follows defaultDark and keeps the toggle available', async () => {
47
+ await render({ defaultDark: false })
48
+ expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to dark mode')
49
+ await render({ defaultDark: true })
50
+ expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to light mode')
51
+ })
52
+
53
+ it('prefers the saved toggle choice over defaultDark', async () => {
54
+ localStorage.setItem('radar-logs-dark', 'true')
55
+ await render({ defaultDark: false })
56
+ expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to light mode')
57
+ })
58
+
59
+ it('returns to the saved choice when a forceDark pin is lifted', async () => {
60
+ localStorage.setItem('radar-logs-dark', 'false')
61
+ await render({ forceDark: true, defaultDark: false })
62
+ expect(toggle()).toBeNull()
63
+ await render({ defaultDark: false })
64
+ expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to dark mode')
65
+ })
66
+
67
+ it('ignores a saved value that is not a palette choice', async () => {
68
+ localStorage.setItem('radar-logs-dark', 'garbage')
69
+ await render({ defaultDark: false })
70
+ expect(toggle()?.getAttribute('aria-label')).toBe('Switch log viewer to dark mode')
71
+ })
72
+
73
+ it('hides the toggle when forceDark is set', async () => {
74
+ await render({ forceDark: true, defaultDark: false })
75
+ expect(toggle()).toBeNull()
76
+ })
77
+ })
@@ -67,6 +67,12 @@ interface LogCoreProps {
67
67
  * the viewer manages its own palette via localStorage and the Sun/Moon button.
68
68
  */
69
69
  forceDark?: boolean
70
+ /**
71
+ * Palette to use when the user hasn't picked one with the Sun/Moon toggle
72
+ * (e.g. the host app's theme). Unlike `forceDark`, the toggle stays visible.
73
+ * Defaults to dark.
74
+ */
75
+ defaultDark?: boolean
70
76
  }
71
77
 
72
78
  interface LevelOption {
@@ -143,12 +149,14 @@ export function LogCore({
143
149
  emptyCommand,
144
150
  errorMessage,
145
151
  forceDark,
152
+ defaultDark = true,
146
153
  }: LogCoreProps) {
147
154
  const virtuosoRef = useRef<VirtuosoHandle>(null)
148
155
  const [atBottom, setAtBottom] = useState(true)
149
156
  const themeLocked = typeof forceDark === 'boolean'
150
157
  // Seed isDark: forceDark prop wins; else localStorage['radar-logs-dark'];
151
- // else default dark. See log-palette.ts for why the viewer is palette-driven
158
+ // else defaultDark (the host app's theme, dark if not given). See
159
+ // log-palette.ts for why the viewer is palette-driven
152
160
  // instead of theme-token-driven.
153
161
  const [isDark, setIsDark] = useState<boolean>(() => {
154
162
  if (typeof forceDark === 'boolean') return forceDark
@@ -157,13 +165,26 @@ export function LogCore({
157
165
  if (v === 'false') return false
158
166
  if (v === 'true') return true
159
167
  } catch {}
160
- return true
168
+ return defaultDark
161
169
  })
162
170
  useEffect(() => {
163
171
  if (typeof forceDark === 'boolean') {
164
172
  setIsDark(forceDark)
165
173
  }
166
174
  }, [forceDark])
175
+ // Re-resolve whenever the host's hint changes, including when a forceDark pin
176
+ // is lifted: the user's saved pick wins, otherwise the host default.
177
+ useEffect(() => {
178
+ if (typeof forceDark === 'boolean') return
179
+ try {
180
+ const v = localStorage.getItem('radar-logs-dark')
181
+ if (v === 'true' || v === 'false') {
182
+ setIsDark(v === 'true')
183
+ return
184
+ }
185
+ } catch {}
186
+ setIsDark(defaultDark)
187
+ }, [defaultDark, forceDark])
167
188
  const palette = useMemo(() => getLogPalette(isDark), [isDark])
168
189
  const toggleDark = useCallback(() => {
169
190
  if (themeLocked) return
@@ -28,8 +28,10 @@ export interface LogsViewerProps {
28
28
  createStream?: (params: Omit<LogsFetchParams, 'previous'>) => EventSource
29
29
  /** Override the download mechanism (e.g. for desktop apps where blob URLs fail). */
30
30
  overrideDownload?: (content: string, mime: string, filename: string) => void
31
- /** Force dark mode on the logs container (default: true) */
31
+ /** Pin the logs container to dark (true) or light (false) and hide the toggle */
32
32
  forceDark?: boolean
33
+ /** Palette used until the user toggles it, e.g. the app theme (default: true) */
34
+ defaultDark?: boolean
33
35
  /**
34
36
  * Open the stream automatically on mount (and on container switch) instead of
35
37
  * loading a static snapshot. The user can still Stop, and a manual Stop is not
@@ -47,6 +49,7 @@ export function LogsViewer({
47
49
  createStream,
48
50
  overrideDownload,
49
51
  forceDark,
52
+ defaultDark,
50
53
  autoStream = false,
51
54
  }: LogsViewerProps) {
52
55
  const [selectedContainer, setSelectedContainer] = useState(initialContainer || containers[0] || '')
@@ -190,6 +193,7 @@ export function LogsViewer({
190
193
  onClear={clear}
191
194
  toolbarExtra={renderToolbarExtra}
192
195
  forceDark={forceDark}
196
+ defaultDark={defaultDark}
193
197
  />
194
198
  )
195
199
  }
@@ -53,8 +53,10 @@ export interface WorkloadLogsViewerProps {
53
53
  createStream?: (params: WorkloadLogsFetchParams) => EventSource
54
54
  /** Override the download mechanism (e.g. for desktop apps where blob URLs fail). */
55
55
  overrideDownload?: (content: string, mime: string, filename: string) => void
56
- /** Force dark mode on the logs container (default: true) */
56
+ /** Pin the logs container to dark (true) or light (false) and hide the toggle */
57
57
  forceDark?: boolean
58
+ /** Palette used until the user toggles it, e.g. the app theme (default: true) */
59
+ defaultDark?: boolean
58
60
  /**
59
61
  * Open the stream automatically on mount (and on container switch) instead of
60
62
  * loading a static snapshot. The user can still Stop, and a manual Stop is not
@@ -63,7 +65,7 @@ export interface WorkloadLogsViewerProps {
63
65
  autoStream?: boolean
64
66
  }
65
67
 
66
- export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, autoStream = false }: WorkloadLogsViewerProps) {
68
+ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false }: WorkloadLogsViewerProps) {
67
69
  const [selectedContainer, setSelectedContainer] = useState<string>('')
68
70
  const [pods, setPods] = useState<WorkloadPodInfo[]>([])
69
71
  const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
@@ -400,6 +402,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
400
402
  emptyCommand={emptyCommand}
401
403
  errorMessage={entries.length === 0 ? fetchError || streamError : null}
402
404
  forceDark={forceDark}
405
+ defaultDark={defaultDark}
403
406
  /></div></div>
404
407
  )
405
408
  }
@@ -0,0 +1,268 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Applying a reviewed edit can be refused because the resource changed after
4
+ // the review. The review then refreshes against the latest version and says
5
+ // so, once. That notice belongs to one attempt: a retry that fails for another
6
+ // reason shows its own error.
7
+ import { act } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
10
+
11
+ vi.mock("../ui/YamlEditor", async () => {
12
+ const { useEffect } = await import("react");
13
+ return {
14
+ YamlEditor: () => <div />,
15
+ YamlDiffEditor: ({
16
+ onReadyChange,
17
+ }: {
18
+ onReadyChange?: (ready: boolean) => void;
19
+ }) => {
20
+ useEffect(() => onReadyChange?.(true), [onReadyChange]);
21
+ return <div />;
22
+ },
23
+ };
24
+ });
25
+
26
+ const { EditableYamlView } = await import("./EditableYamlView");
27
+
28
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
29
+ vi.stubGlobal(
30
+ "ResizeObserver",
31
+ class {
32
+ observe() {}
33
+ unobserve() {}
34
+ disconnect() {}
35
+ },
36
+ );
37
+ let root: Root;
38
+ let host: HTMLDivElement;
39
+ beforeEach(() => {
40
+ localStorage.clear();
41
+ sessionStorage.clear();
42
+ host = document.createElement("div");
43
+ document.body.appendChild(host);
44
+ root = createRoot(host);
45
+ });
46
+ afterEach(() => {
47
+ act(() => root.unmount());
48
+ host.remove();
49
+ });
50
+
51
+ const resource = { kind: "ConfigMap", namespace: "shop", name: "app" };
52
+ const data = {
53
+ apiVersion: "v1",
54
+ kind: "ConfigMap",
55
+ metadata: { name: "app", namespace: "shop" },
56
+ data: { a: "1" },
57
+ };
58
+ const reviewed = (rv: string) => ({
59
+ documents: [
60
+ {
61
+ index: 0,
62
+ kind: "ConfigMap",
63
+ name: "app",
64
+ namespace: "shop",
65
+ status: "accepted" as const,
66
+ action: "update" as const,
67
+ reviewedResourceVersion: rv,
68
+ },
69
+ ],
70
+ nonAtomic: false,
71
+ });
72
+
73
+ function render(props: {
74
+ onSave: () => Promise<void>;
75
+ onPreview: () => Promise<ReturnType<typeof reviewed>>;
76
+ saveError?: string | null;
77
+ }) {
78
+ act(() =>
79
+ root.render(
80
+ <EditableYamlView
81
+ resource={resource as never}
82
+ data={data}
83
+ onCopy={() => {}}
84
+ copied={false}
85
+ onSave={props.onSave}
86
+ onPreview={props.onPreview}
87
+ saveError={props.saveError ?? null}
88
+ />,
89
+ ),
90
+ );
91
+ }
92
+
93
+ const click = async (label: RegExp) => {
94
+ const b = [...document.querySelectorAll("button")].find((x) =>
95
+ label.test(x.textContent ?? ""),
96
+ );
97
+ if (!b) throw new Error(`no button ${label} in ${document.body.textContent}`);
98
+ await act(async () => {
99
+ b.click();
100
+ await new Promise((r) => setTimeout(r, 0));
101
+ });
102
+ };
103
+ const text = () => document.body.textContent ?? "";
104
+
105
+ it("says the resource changed after review, and only for the attempt that found it", async () => {
106
+ const onPreview = vi
107
+ .fn()
108
+ .mockResolvedValueOnce(reviewed("1")) // the review
109
+ .mockResolvedValueOnce(reviewed("2")) // refresh after the conflict: a newer version
110
+ .mockRejectedValueOnce(new Error("preview unavailable")); // refresh after the second failure
111
+ const onSave = vi
112
+ .fn()
113
+ .mockRejectedValueOnce(new Error("conflict"))
114
+ .mockRejectedValueOnce(new Error("forbidden"));
115
+ render({ onSave, onPreview });
116
+
117
+ await click(/^Edit$/);
118
+ await click(/Review changes/);
119
+ await click(/Apply reviewed changes/);
120
+ render({
121
+ onSave,
122
+ onPreview,
123
+ saveError:
124
+ "resource changed after review; review the latest version before applying",
125
+ });
126
+ expect(text()).toContain("This resource changed after your review");
127
+ expect(text()).not.toContain("review the latest version before applying");
128
+
129
+ await click(/Apply reviewed changes/);
130
+ render({
131
+ onSave,
132
+ onPreview,
133
+ saveError: "forbidden: cannot patch configmaps",
134
+ });
135
+ expect(text()).not.toContain("This resource changed after your review");
136
+ expect(text()).toContain("forbidden: cannot patch configmaps");
137
+ expect(onSave).toHaveBeenCalledTimes(2);
138
+ expect(onPreview).toHaveBeenCalledTimes(3);
139
+ });
140
+
141
+ it("says nothing extra when the refreshed review found the same version", async () => {
142
+ const onPreview = vi.fn().mockResolvedValue(reviewed("1"));
143
+ const onSave = vi.fn().mockRejectedValueOnce(new Error("webhook denied"));
144
+ render({ onSave, onPreview });
145
+ await click(/^Edit$/);
146
+ await click(/Review changes/);
147
+ await click(/Apply reviewed changes/);
148
+ render({
149
+ onSave,
150
+ onPreview,
151
+ saveError: "admission webhook denied the request",
152
+ });
153
+ expect(text()).not.toContain("This resource changed after your review");
154
+ expect(text()).toContain("admission webhook denied the request");
155
+ expect(onPreview).toHaveBeenCalledTimes(2);
156
+ });
157
+
158
+ it("doesn't call a deleted resource changed", async () => {
159
+ const gone = {
160
+ documents: [
161
+ {
162
+ index: 0,
163
+ kind: "ConfigMap",
164
+ name: "app",
165
+ namespace: "shop",
166
+ status: "rejected" as const,
167
+ message: 'configmaps "app" not found',
168
+ },
169
+ ],
170
+ nonAtomic: false,
171
+ };
172
+ const onPreview = vi
173
+ .fn()
174
+ .mockResolvedValueOnce(reviewed("1"))
175
+ .mockResolvedValueOnce(gone);
176
+ const onSave = vi.fn().mockRejectedValueOnce(new Error("not found"));
177
+ render({ onSave, onPreview });
178
+ await click(/^Edit$/);
179
+ await click(/Review changes/);
180
+ await click(/Apply reviewed changes/);
181
+ render({ onSave, onPreview, saveError: 'configmaps "app" not found' });
182
+ expect(text()).not.toContain("This resource changed after your review");
183
+ expect(text()).toContain('configmaps "app" not found');
184
+ });
185
+
186
+ it("doesn't call a switched cluster a changed resource", async () => {
187
+ const onPreview = vi
188
+ .fn()
189
+ .mockResolvedValueOnce({ ...reviewed("1"), context: "kind-a" })
190
+ .mockResolvedValueOnce({ ...reviewed("2"), context: "kind-b" });
191
+ const onSave = vi.fn().mockRejectedValueOnce(new Error("cluster changed"));
192
+ render({ onSave, onPreview });
193
+ await click(/^Edit$/);
194
+ await click(/Review changes/);
195
+ await click(/Apply reviewed changes/);
196
+ render({
197
+ onSave,
198
+ onPreview,
199
+ saveError: "the active cluster changed after review",
200
+ });
201
+ expect(text()).not.toContain("This resource changed after your review");
202
+ expect(text()).toContain("the active cluster changed after review");
203
+ });
204
+
205
+ it("ignores an earlier attempt's refresh that lands after a newer attempt", async () => {
206
+ let finishFirstRefresh: (v: ReturnType<typeof reviewed>) => void = () => {};
207
+ const onPreview = vi
208
+ .fn()
209
+ .mockResolvedValueOnce(reviewed("1"))
210
+ .mockReturnValueOnce(
211
+ new Promise<ReturnType<typeof reviewed>>((r) => {
212
+ finishFirstRefresh = r;
213
+ }),
214
+ )
215
+ .mockRejectedValueOnce(new Error("preview unavailable"));
216
+ const onSave = vi
217
+ .fn()
218
+ .mockRejectedValueOnce(new Error("conflict"))
219
+ .mockRejectedValueOnce(new Error("forbidden"));
220
+ render({ onSave, onPreview });
221
+ await click(/^Edit$/);
222
+ await click(/Review changes/);
223
+ await click(/Apply reviewed changes/);
224
+ await click(/Apply reviewed changes/);
225
+ await act(async () => {
226
+ finishFirstRefresh(reviewed("2"));
227
+ await new Promise((r) => setTimeout(r, 0));
228
+ });
229
+ render({
230
+ onSave,
231
+ onPreview,
232
+ saveError: "forbidden: cannot patch configmaps",
233
+ });
234
+ expect(text()).not.toContain("This resource changed after your review");
235
+ expect(text()).toContain("forbidden: cannot patch configmaps");
236
+ });
237
+
238
+ it("drops a refresh from an apply made before going back to a newer review", async () => {
239
+ let finishFirstRefresh: (v: ReturnType<typeof reviewed>) => void = () => {};
240
+ const onPreview = vi
241
+ .fn()
242
+ .mockResolvedValueOnce(reviewed("1"))
243
+ .mockReturnValueOnce(
244
+ new Promise<ReturnType<typeof reviewed>>((r) => {
245
+ finishFirstRefresh = r;
246
+ }),
247
+ )
248
+ .mockResolvedValueOnce(reviewed("3"));
249
+ const onSave = vi
250
+ .fn()
251
+ .mockRejectedValueOnce(new Error("conflict"))
252
+ .mockResolvedValueOnce(undefined);
253
+ render({ onSave, onPreview });
254
+ await click(/^Edit$/);
255
+ await click(/Review changes/);
256
+ await click(/Apply reviewed changes/);
257
+ await click(/Back to edit/);
258
+ await click(/Review changes/);
259
+ await act(async () => {
260
+ finishFirstRefresh(reviewed("2"));
261
+ await new Promise((r) => setTimeout(r, 0));
262
+ });
263
+ expect(text()).not.toContain("This resource changed after your review");
264
+ await click(/Apply reviewed changes/);
265
+ expect(onSave).toHaveBeenLastCalledWith(
266
+ expect.objectContaining({ reviewedResourceVersion: "3" }),
267
+ );
268
+ });
@@ -202,6 +202,9 @@ export function EditableYamlView({
202
202
  // All sessionStorage calls are wrapped in try-catch — storage can throw
203
203
  // QuotaExceededError or be blocked by browser security policies.
204
204
  const savedDraft = useRef(readOnly ? null : safeSessionGet(draftKey))
205
+ // Bumped by every apply and every change of review, so a refresh that lands
206
+ // late never replaces the review that is on screen now.
207
+ const reviewGeneration = useRef(0)
205
208
  const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
206
209
  const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
207
210
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
@@ -216,6 +219,8 @@ export function EditableYamlView({
216
219
  documents: YamlPreviewResult[]
217
220
  nonAtomic: boolean
218
221
  context?: string
222
+ // An apply failed and the refreshed review found a newer resource version.
223
+ changedSinceReview?: boolean
219
224
  } | null>(null)
220
225
 
221
226
  // Clean up restored draft flag
@@ -253,6 +258,7 @@ export function EditableYamlView({
253
258
 
254
259
  const handleStartEdit = useCallback(() => {
255
260
  if (readOnly) return
261
+ reviewGeneration.current++
256
262
  setEditedYaml(resourceToYaml(data))
257
263
  setYamlErrors([])
258
264
  setPreview(null)
@@ -260,6 +266,7 @@ export function EditableYamlView({
260
266
  }, [data, readOnly])
261
267
 
262
268
  const handleCancelEdit = useCallback(() => {
269
+ reviewGeneration.current++
263
270
  setIsEditing(false)
264
271
  setEditedYaml('')
265
272
  setYamlErrors([])
@@ -268,6 +275,7 @@ export function EditableYamlView({
268
275
 
269
276
  const handleSaveEdit = useCallback(async () => {
270
277
  if (yamlErrors.length > 0 || !onSave) return
278
+ reviewGeneration.current++
271
279
 
272
280
  try {
273
281
  if (onPreview) {
@@ -308,6 +316,10 @@ export function EditableYamlView({
308
316
 
309
317
  const handleApplyReviewed = useCallback(async () => {
310
318
  if (!preview || !onSave) return
319
+ // The notice belongs to one attempt: a retry that fails for another reason
320
+ // (and can't refresh) must show its own error, not the last notice.
321
+ const attempt = ++reviewGeneration.current
322
+ if (preview.changedSinceReview) setPreview({ ...preview, changedSinceReview: false })
311
323
  try {
312
324
  await onSave({
313
325
  kind: resource.kind,
@@ -335,11 +347,19 @@ export function EditableYamlView({
335
347
  name: resource.name,
336
348
  },
337
349
  })
350
+ if (attempt !== reviewGeneration.current) return
351
+ // Changed means the same resource, on the same cluster, at a newer
352
+ // version. A deleted resource has no version, and a cluster switch
353
+ // is its own error.
354
+ const before = preview.documents[0]?.reviewedResourceVersion
355
+ const after = refreshed.documents[0]?.reviewedResourceVersion
338
356
  setPreview({
339
357
  ...preview,
340
358
  documents: refreshed.documents,
341
359
  nonAtomic: refreshed.nonAtomic,
342
360
  context: refreshed.context,
361
+ changedSinceReview:
362
+ !!after && after !== before && refreshed.context === preview.context,
343
363
  })
344
364
  } catch {
345
365
  // Keep the last review visible when refresh is unavailable.
@@ -368,7 +388,11 @@ export function EditableYamlView({
368
388
  force={preview.force}
369
389
  isApplying={isSaving}
370
390
  applyError={saveError}
371
- onBack={() => setPreview(null)}
391
+ changedSinceReview={preview.changedSinceReview}
392
+ onBack={() => {
393
+ reviewGeneration.current++
394
+ setPreview(null)
395
+ }}
372
396
  onApply={handleApplyReviewed}
373
397
  />
374
398
  )
@@ -0,0 +1,25 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { SelectMenu } from './SelectMenu'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ it('keeps one option tabbable when the options shrink while the menu is open', async () => {
10
+ const element = document.createElement('div')
11
+ document.body.append(element)
12
+ const root = createRoot(element)
13
+ const render = (options: { value: string; label: string }[]) =>
14
+ root.render(<SelectMenu value="c" onChange={() => {}} options={options} ariaLabel="Source" />)
15
+ await act(async () => render([{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }, { value: 'c', label: 'C' }]))
16
+ await act(async () => { element.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!.click() })
17
+ expect(element.querySelectorAll('[role="option"]')).toHaveLength(3)
18
+
19
+ await act(async () => render([{ value: 'a', label: 'A' }]))
20
+ const tabbable = element.querySelectorAll('[role="option"][tabindex="0"]')
21
+ expect(tabbable).toHaveLength(1)
22
+ expect(tabbable[0].textContent).toBe('A')
23
+ await act(async () => root.unmount())
24
+ element.remove()
25
+ })
@@ -55,7 +55,9 @@ export function SelectMenu({
55
55
  if (!normalized) return options
56
56
  return options.filter((option) => `${option.label} ${option.description ?? ''}`.toLowerCase().includes(normalized))
57
57
  }, [options, query])
58
- const selectedIsVisible = filteredOptions.some((option) => option.value === value)
58
+ // Options can be replaced while the menu is open, leaving the highlight past
59
+ // the end; without the clamp no option is tabbable and Tab skips the list.
60
+ const activeIndex = Math.min(highlightedIndex, filteredOptions.length - 1)
59
61
 
60
62
  const focusTabbableOption = () => {
61
63
  listRef.current?.querySelector<HTMLElement>('[role="option"][tabindex="0"]')?.focus()
@@ -153,7 +155,9 @@ export function SelectMenu({
153
155
  ref={triggerRef}
154
156
  id={id}
155
157
  type="button"
156
- aria-label={ariaLabel}
158
+ // The fixed label would otherwise hide the visible value from screen
159
+ // readers, which a native select announces.
160
+ aria-label={selected ? `${ariaLabel}: ${selected.label}` : ariaLabel}
157
161
  aria-describedby={ariaDescribedBy}
158
162
  aria-haspopup="listbox"
159
163
  aria-expanded={open}
@@ -216,11 +220,11 @@ export function SelectMenu({
216
220
  onKeyDown={(event) => {
217
221
  if (event.key === 'Enter' && filteredOptions.length > 0) {
218
222
  event.preventDefault()
219
- selectOption(filteredOptions[Math.min(highlightedIndex, filteredOptions.length - 1)].value)
223
+ selectOption(filteredOptions[activeIndex].value)
220
224
  } else if (event.key === 'ArrowDown') {
221
225
  event.preventDefault()
222
226
  const optionElements = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]')
223
- optionElements?.[Math.min(highlightedIndex, optionElements.length - 1)]?.focus()
227
+ optionElements?.[activeIndex]?.focus()
224
228
  }
225
229
  }}
226
230
  aria-label={searchPlaceholder}
@@ -230,7 +234,7 @@ export function SelectMenu({
230
234
  aria-expanded="true"
231
235
  aria-activedescendant={
232
236
  filteredOptions.length > 0
233
- ? `${listboxId}-option-${Math.min(highlightedIndex, filteredOptions.length - 1)}`
237
+ ? `${listboxId}-option-${activeIndex}`
234
238
  : undefined
235
239
  }
236
240
  placeholder={searchPlaceholder}
@@ -283,15 +287,13 @@ export function SelectMenu({
283
287
  role="option"
284
288
  aria-selected={active}
285
289
  aria-disabled={option.disabled || undefined}
286
- tabIndex={
287
- searchPlaceholder ? (index === highlightedIndex ? 0 : -1) : active || (!selectedIsVisible && index === 0) ? 0 : -1
288
- }
290
+ tabIndex={index === activeIndex ? 0 : -1}
289
291
  onClick={() => selectOption(option.value)}
290
292
  onFocus={() => setHighlightedIndex(index)}
291
293
  className={clsx(
292
294
  'flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-theme-text-secondary transition-colors',
293
295
  option.disabled ? 'cursor-not-allowed opacity-60' : 'hover:bg-theme-hover hover:text-theme-text-primary',
294
- searchPlaceholder && index === highlightedIndex && 'bg-theme-hover text-theme-text-primary',
296
+ searchPlaceholder && index === activeIndex && 'bg-theme-hover text-theme-text-primary',
295
297
  !searchPlaceholder && 'whitespace-nowrap'
296
298
  )}
297
299
  >
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { YamlReview } from './YamlReview'
4
+
5
+ const doc = { index: 0, kind: 'ConfigMap', name: 'app', namespace: 'shop', status: 'ok' as const, action: 'update' as const, reviewedResourceVersion: '42' }
6
+
7
+ function review(changedSinceReview?: boolean, applyError?: string) {
8
+ return renderToString(
9
+ <YamlReview
10
+ submittedYaml="kind: ConfigMap"
11
+ documents={[doc]}
12
+ changedSinceReview={changedSinceReview}
13
+ applyError={applyError}
14
+ onBack={() => {}}
15
+ onApply={() => {}}
16
+ />,
17
+ )
18
+ }
19
+
20
+ describe('YamlReview after a failed apply', () => {
21
+ it('says the review now shows the latest version when the resource changed under it', () => {
22
+ const html = review(true, 'resource changed after review; review the latest version before applying')
23
+ expect(html).toContain('This resource changed after your review')
24
+ expect(html).toContain('apply again')
25
+ // Said once: the raw API message would repeat the notice.
26
+ expect(html).not.toContain('review the latest version before applying')
27
+ })
28
+
29
+ it('keeps an unrelated apply error beside the notice', () => {
30
+ const html = review(true, 'admission webhook "policy.example" denied the request')
31
+ expect(html).toContain('This resource changed after your review')
32
+ expect(html).toContain('denied the request')
33
+ })
34
+
35
+ it('says nothing extra when the resource did not change', () => {
36
+ expect(review(false, 'forbidden')).not.toContain('changed after your review')
37
+ expect(review(false, 'forbidden')).toContain('forbidden')
38
+ expect(review()).not.toContain('changed after your review')
39
+ })
40
+ })
@@ -4,6 +4,7 @@ import { normalizeYamlForReview, splitYamlDocuments } from '../../utils/yaml'
4
4
  import { Badge } from './Badge'
5
5
  import { StatusDot } from './status-tone'
6
6
  import { YamlDiffEditor } from './YamlEditor'
7
+ import { AlertBanner } from './drawer-components'
7
8
 
8
9
  export interface YamlPreviewResult {
9
10
  index: number
@@ -30,6 +31,9 @@ export interface YamlReviewProps {
30
31
  force?: boolean
31
32
  isApplying?: boolean
32
33
  applyError?: string | null
34
+ /** Set when an apply failed and the refreshed review found the resource had
35
+ * changed since the user reviewed it: the diff now shows its latest version. */
36
+ changedSinceReview?: boolean
33
37
  applyLabel?: string
34
38
  onClose?: () => void
35
39
  onBack: () => void
@@ -48,6 +52,13 @@ function previewSeverity(status: YamlPreviewResult['status']) {
48
52
  return 'warning' as const
49
53
  }
50
54
 
55
+ // The server's refusal when the resource moved past the reviewed version. The
56
+ // changed-after-review notice already says this, so only this error gives way
57
+ // to it; a timeout or an admission denial still shows beside the notice.
58
+ function isChangedAfterReviewError(error: string) {
59
+ return /resource changed after review/i.test(error)
60
+ }
61
+
51
62
  export function canApplyYamlPreview(
52
63
  documents: YamlPreviewResult[],
53
64
  unavailableAcknowledged: boolean,
@@ -80,6 +91,7 @@ export function YamlReview({
80
91
  force = false,
81
92
  isApplying = false,
82
93
  applyError,
94
+ changedSinceReview = false,
83
95
  applyLabel = 'Apply reviewed changes',
84
96
  onClose,
85
97
  onBack,
@@ -351,7 +363,16 @@ export function YamlReview({
351
363
  </div>
352
364
 
353
365
  <div className="shrink-0 border-t border-theme-border bg-theme-elevated/80 px-4 py-3">
354
- {applyError && (
366
+ {changedSinceReview && (
367
+ <AlertBanner
368
+ variant="warning"
369
+ title="This resource changed after your review"
370
+ message="The diff above now shows its latest version. Check it, then apply again."
371
+ />
372
+ )}
373
+ {/* The notice above already says why the apply was refused; the
374
+ raw API message would repeat it (the error toast carries it). */}
375
+ {applyError && !(changedSinceReview && isChangedAfterReviewError(applyError)) && (
355
376
  <div className="mb-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-600 dark:text-red-400">
356
377
  {applyError}
357
378
  </div>
package/src/types/core.ts CHANGED
@@ -166,6 +166,7 @@ export interface FeatureCapabilities {
166
166
  resourceIssues?: boolean
167
167
  podEnvironment?: boolean
168
168
  policyResource?: boolean
169
+ workloadHistory?: boolean
169
170
  }
170
171
 
171
172
  // DeploymentMode is the closed set of topologies Radar can run in.