@skyhook-io/k8s-ui 1.16.1 → 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 +22 -0
- package/package.json +1 -1
- package/src/components/dock/BottomDock.tsx +8 -1
- package/src/components/dock/DockContext.tsx +8 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +189 -0
- package/src/components/dock/LocalTerminalTab.tsx +56 -18
- package/src/components/dock/TerminalTab.tsx +2 -4
- package/src/components/logs/LogCore.theme.test.tsx +77 -0
- package/src/components/logs/LogCore.tsx +23 -2
- package/src/components/logs/LogsViewer.tsx +5 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +5 -2
- package/src/components/shared/EditableYamlView.test.tsx +268 -0
- package/src/components/shared/EditableYamlView.tsx +25 -1
- package/src/components/ui/SelectMenu.test.tsx +25 -0
- package/src/components/ui/SelectMenu.tsx +11 -9
- package/src/components/ui/YamlReview.test.tsx +40 -0
- package/src/components/ui/YamlReview.tsx +22 -1
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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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?.[
|
|
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-${
|
|
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 ===
|
|
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
|
-
{
|
|
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>
|