@skyhook-io/k8s-ui 1.16.1 → 1.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/package.json +3 -3
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- 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/issues/IssuesView.tsx +10 -1
- package/src/components/issues/types.ts +24 -0
- 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 +16 -4
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +5 -1
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -3
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/shared/EditableYamlView.test.tsx +268 -0
- package/src/components/shared/EditableYamlView.tsx +25 -1
- package/src/components/timeline/TimelineList.tsx +185 -23
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- 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/src/components/workload/WorkloadView.tsx +96 -5
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- package/src/utils/log-level.ts +14 -0
|
@@ -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>
|
|
@@ -403,7 +403,16 @@ export function IssueRow({
|
|
|
403
403
|
function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSource }) {
|
|
404
404
|
const crash =
|
|
405
405
|
issue.restart_count || issue.last_terminated_reason
|
|
406
|
-
? [
|
|
406
|
+
? [
|
|
407
|
+
issue.restart_count ? `${issue.restart_count} restart${issue.restart_count === 1 ? '' : 's'}` : null,
|
|
408
|
+
issue.last_terminated_reason ? `last exit: ${issue.last_terminated_reason}` : null,
|
|
409
|
+
issue.restart_loop?.workload_pods && issue.restart_loop.workload_pods > 1
|
|
410
|
+
? `${issue.restart_loop.looping_pods} of ${issue.restart_loop.workload_pods} pods looping`
|
|
411
|
+
: null,
|
|
412
|
+
issue.restart_loop?.startup_probe_failure ? 'startup probe failures seen' : null,
|
|
413
|
+
issue.restart_loop?.liveness_probe_failure ? 'liveness probe failures seen' : null,
|
|
414
|
+
issue.restart_loop?.readiness_probe_failure ? 'readiness probe failures seen' : null,
|
|
415
|
+
]
|
|
407
416
|
.filter(Boolean)
|
|
408
417
|
.join(' · ')
|
|
409
418
|
: null;
|
|
@@ -230,6 +230,9 @@ export interface Issue {
|
|
|
230
230
|
// Pod crash context carried from the representative member.
|
|
231
231
|
restart_count?: number;
|
|
232
232
|
last_terminated_reason?: string;
|
|
233
|
+
/** Evidence for a container in an active restart loop (crashloop issues).
|
|
234
|
+
* Probe failures are observations alongside the restarts, not a cause. */
|
|
235
|
+
restart_loop?: IssueRestartLoop;
|
|
233
236
|
|
|
234
237
|
/**
|
|
235
238
|
* Best-effort timing evidence from K8s-native signals. Absent when Radar has
|
|
@@ -360,3 +363,24 @@ export function issueMessageParts(issue: Issue): { headline: string; detail: str
|
|
|
360
363
|
if (normalized && normalized !== raw) return { headline: normalized, detail: raw };
|
|
361
364
|
return { headline: raw, detail: '' };
|
|
362
365
|
}
|
|
366
|
+
|
|
367
|
+
export interface IssueProbeFailure {
|
|
368
|
+
last_seen: string;
|
|
369
|
+
message?: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface IssueRestartLoop {
|
|
373
|
+
container: string;
|
|
374
|
+
sidecar?: boolean;
|
|
375
|
+
restart_count: number;
|
|
376
|
+
last_exit_code: number;
|
|
377
|
+
last_reason?: string;
|
|
378
|
+
last_finished_at?: string;
|
|
379
|
+
last_started_at?: string;
|
|
380
|
+
looping_pods?: number;
|
|
381
|
+
workload_pods?: number;
|
|
382
|
+
severity_reason?: string;
|
|
383
|
+
liveness_probe_failure?: IssueProbeFailure;
|
|
384
|
+
readiness_probe_failure?: IssueProbeFailure;
|
|
385
|
+
startup_probe_failure?: IssueProbeFailure;
|
|
386
|
+
}
|
|
@@ -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
|