@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.
Files changed (53) hide show
  1. package/README.md +22 -0
  2. package/package.json +3 -3
  3. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  4. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  5. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  6. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  7. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  8. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  9. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  10. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  11. package/src/components/cnpg/index.ts +14 -0
  12. package/src/components/cnpg/primitives.tsx +134 -0
  13. package/src/components/cnpg/relations.test.ts +304 -0
  14. package/src/components/cnpg/relations.ts +417 -0
  15. package/src/components/cnpg/workspace.test.ts +285 -0
  16. package/src/components/cnpg/workspace.ts +700 -0
  17. package/src/components/dock/BottomDock.tsx +8 -1
  18. package/src/components/dock/DockContext.tsx +8 -0
  19. package/src/components/dock/LocalTerminalTab.test.tsx +189 -0
  20. package/src/components/dock/LocalTerminalTab.tsx +56 -18
  21. package/src/components/dock/TerminalTab.tsx +2 -4
  22. package/src/components/issues/IssuesView.tsx +10 -1
  23. package/src/components/issues/types.ts +24 -0
  24. package/src/components/logs/LogCore.theme.test.tsx +77 -0
  25. package/src/components/logs/LogCore.tsx +23 -2
  26. package/src/components/logs/LogsViewer.tsx +5 -1
  27. package/src/components/logs/WorkloadLogsViewer.tsx +16 -4
  28. package/src/components/logs/useLogBuffer.test.ts +17 -0
  29. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  30. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  31. package/src/components/resources/ResourcesView.tsx +5 -1
  32. package/src/components/resources/index.ts +1 -1
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  34. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  35. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  36. package/src/components/resources/resource-utils-cnpg.ts +3 -3
  37. package/src/components/resources/status-overclaim.test.ts +1 -1
  38. package/src/components/shared/EditableYamlView.test.tsx +268 -0
  39. package/src/components/shared/EditableYamlView.tsx +25 -1
  40. package/src/components/timeline/TimelineList.tsx +185 -23
  41. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  42. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  43. package/src/components/timeline/index.ts +1 -0
  44. package/src/components/timeline/timeline-filters.ts +7 -0
  45. package/src/components/ui/SelectMenu.test.tsx +25 -0
  46. package/src/components/ui/SelectMenu.tsx +11 -9
  47. package/src/components/ui/YamlReview.test.tsx +40 -0
  48. package/src/components/ui/YamlReview.tsx +22 -1
  49. package/src/components/workload/WorkloadView.tsx +96 -5
  50. package/src/components/workload/index.ts +1 -0
  51. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  52. package/src/index.ts +3 -0
  53. 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
- <span className="truncate max-w-[120px]">{tab.title}</span>
228
+ {tab.titleTooltip ? (
229
+ <Tooltip content={tab.titleTooltip} wrapperClassName="min-w-0">
230
+ <span className="block truncate max-w-[120px]">{tab.title}</span>
231
+ </Tooltip>
232
+ ) : (
233
+ <span className="truncate max-w-[120px]">{tab.title}</span>
234
+ )}
228
235
  <button
229
236
  onClick={(e) => {
230
237
  e.stopPropagation()
@@ -6,6 +6,7 @@ export interface DockTab {
6
6
  id: string
7
7
  type: DockTabType
8
8
  title: string
9
+ titleTooltip?: string
9
10
  // Common app context (optional — app-specific, used by tab content components)
10
11
  orgId?: string
11
12
  clusterId?: string
@@ -22,6 +23,7 @@ export interface DockTab {
22
23
  nodeName?: string
23
24
  // Local terminal props
24
25
  initialCommand?: string
26
+ localTerminalContext?: string
25
27
  }
26
28
 
27
29
  export interface DockContextValue {
@@ -33,6 +35,7 @@ export interface DockContextValue {
33
35
  isMaximized: boolean
34
36
  isResizing: boolean
35
37
  addTab: (tab: Omit<DockTab, 'id'>) => string
38
+ setTabTitle: (id: string, title: string, titleTooltip?: string) => void
36
39
  removeTab: (id: string) => void
37
40
  setActiveTab: (id: string) => void
38
41
  toggleExpanded: () => void
@@ -118,6 +121,10 @@ export function DockProvider({ children }: { children: ReactNode }) {
118
121
  })
119
122
  }, [activeTabId])
120
123
 
124
+ const setTabTitle = useCallback((id: string, title: string, titleTooltip?: string) => {
125
+ setTabs(prev => prev.map(tab => tab.id === id ? { ...tab, title, titleTooltip } : tab))
126
+ }, [])
127
+
121
128
  const setActiveTab = useCallback((id: string) => {
122
129
  setActiveTabId(id)
123
130
  }, [])
@@ -142,6 +149,7 @@ export function DockProvider({ children }: { children: ReactNode }) {
142
149
  isMaximized,
143
150
  isResizing,
144
151
  addTab,
152
+ setTabTitle,
145
153
  removeTab,
146
154
  setActiveTab,
147
155
  toggleExpanded,
@@ -0,0 +1,189 @@
1
+ // @vitest-environment jsdom
2
+ import { act, StrictMode } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+ import { LocalTerminalTab, type LocalTerminalTabProps } from './LocalTerminalTab'
6
+ import { TerminalTab } from './TerminalTab'
7
+
8
+ vi.mock('@xterm/xterm', () => ({
9
+ Terminal: class {
10
+ rows = 24
11
+ cols = 80
12
+ loadAddon() {}
13
+ open() {}
14
+ resize() {}
15
+ focus() {}
16
+ dispose() {}
17
+ write() {}
18
+ onData() {}
19
+ },
20
+ }))
21
+ vi.mock('@xterm/addon-fit', () => ({ FitAddon: class { proposeDimensions() { return { rows: 24, cols: 80 } } } }))
22
+ vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: class {} }))
23
+ vi.mock('./terminalClipboard', () => ({ setupTerminalClipboard: () => () => {}, copyTerminalSelection: vi.fn() }))
24
+ vi.mock('./TerminalClipboardToolbar', () => ({ TerminalClipboardToolbar: () => null }))
25
+ vi.mock('./useMultilinePasteConfirm', () => ({ useMultilinePasteConfirm: () => ({ confirmPaste: vi.fn(), pasteDialog: null }) }))
26
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
27
+
28
+ class Socket {
29
+ static OPEN = 1
30
+ static instances: Socket[] = []
31
+ readyState = 1
32
+ onopen?: () => void
33
+ onclose?: () => void
34
+ onmessage?: (event: { data: string }) => void
35
+ send = vi.fn()
36
+ close() { this.readyState = 3; this.onclose?.() }
37
+ constructor() { Socket.instances.push(this) }
38
+ session(context: string) {
39
+ this.onmessage?.({ data: JSON.stringify({ type: 'session', context, kubeconfigIsolated: true }) })
40
+ }
41
+ }
42
+ let root: Root
43
+ let element: HTMLDivElement
44
+ beforeEach(() => {
45
+ Socket.instances = []
46
+ vi.stubGlobal('WebSocket', Socket)
47
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
48
+ vi.stubGlobal('requestAnimationFrame', () => 0)
49
+ element = document.createElement('div')
50
+ document.body.appendChild(element)
51
+ root = createRoot(element)
52
+ })
53
+ afterEach(async () => {
54
+ await act(async () => root.unmount())
55
+ element.remove()
56
+ vi.unstubAllGlobals()
57
+ vi.useRealTimers()
58
+ })
59
+ async function render(props: Partial<LocalTerminalTabProps> = {}, strict = false) {
60
+ await act(async () => {
61
+ const terminal = <LocalTerminalTab createSession={async () => ({ wsUrl: 'ws://localhost' })} {...props} />
62
+ root.render(strict ? <StrictMode>{terminal}</StrictMode> : terminal)
63
+ })
64
+ }
65
+
66
+ it('reports the current session, clears metadata on reconnect, and ignores old socket messages', async () => {
67
+ const firstCallback = vi.fn()
68
+ await render({ onSessionInfo: firstCallback })
69
+ expect(firstCallback).toHaveBeenCalledWith(null)
70
+ const old = Socket.instances[0]
71
+ await act(async () => { old.onopen?.(); old.session('production') })
72
+ expect(firstCallback).toHaveBeenLastCalledWith({ context: 'production', kubeconfigIsolated: true })
73
+ const currentCallback = vi.fn()
74
+ await render({ onSessionInfo: currentCallback })
75
+ await act(async () => old.close())
76
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
77
+ expect(currentCallback).toHaveBeenLastCalledWith(null)
78
+ const current = Socket.instances[1]
79
+ await act(async () => { current.onopen?.(); current.session('staging'); old.session('production') })
80
+ expect(currentCallback).toHaveBeenLastCalledWith({ context: 'staging', kubeconfigIsolated: true })
81
+ expect(currentCallback).toHaveBeenCalledTimes(2)
82
+ })
83
+
84
+ it('does not open an obsolete session when Strict Mode creations resolve out of order', async () => {
85
+ let resolveOld!: (value: { wsUrl: string }) => void
86
+ const pending = new Promise<{ wsUrl: string }>(resolve => { resolveOld = resolve })
87
+ const createSession = vi.fn().mockReturnValueOnce(pending).mockResolvedValueOnce({ wsUrl: 'ws://localhost/current' })
88
+ const onSessionInfo = vi.fn()
89
+ await render({ createSession, onSessionInfo }, true)
90
+ expect(createSession).toHaveBeenCalledTimes(2)
91
+ expect(Socket.instances).toHaveLength(1)
92
+ await act(async () => resolveOld({ wsUrl: 'ws://localhost/obsolete' }))
93
+ expect(Socket.instances).toHaveLength(1)
94
+ await act(async () => Socket.instances[0].session('current'))
95
+ expect(onSessionInfo).toHaveBeenLastCalledWith({ context: 'current', kubeconfigIsolated: true })
96
+ })
97
+
98
+ it('sends the initial command once and does not replay it on reconnect', async () => {
99
+ vi.useFakeTimers()
100
+ await render({ initialCommand: 'aws sso login' })
101
+ const first = Socket.instances[0]
102
+ await act(async () => first.onopen?.())
103
+ expect(first.send).not.toHaveBeenCalledWith(JSON.stringify({ type: 'input', data: 'aws sso login\n' }))
104
+ await act(async () => vi.advanceTimersByTime(300))
105
+ const inputs = () => Socket.instances.flatMap(socket => socket.send.mock.calls.map(([data]) => JSON.parse(data))).filter(message => message.type === 'input')
106
+ expect(inputs()).toEqual([{ type: 'input', data: 'aws sso login\n' }])
107
+
108
+ await act(async () => first.close())
109
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
110
+ await act(async () => Socket.instances[1].onopen?.())
111
+ await act(async () => vi.advanceTimersByTime(300))
112
+ expect(inputs()).toEqual([{ type: 'input', data: 'aws sso login\n' }])
113
+ })
114
+
115
+ it('keeps an unsent initial command available when the first connection closes before delivery', async () => {
116
+ vi.useFakeTimers()
117
+ await render({ initialCommand: 'gcloud auth login' })
118
+ const first = Socket.instances[0]
119
+ await act(async () => first.onopen?.())
120
+ await act(async () => vi.advanceTimersByTime(100))
121
+ await act(async () => first.close())
122
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
123
+ const second = Socket.instances[1]
124
+ await act(async () => second.onopen?.())
125
+ await act(async () => vi.advanceTimersByTime(200))
126
+ const input = JSON.stringify({ type: 'input', data: 'gcloud auth login\n' })
127
+ expect(first.send).not.toHaveBeenCalledWith(input)
128
+ expect(second.send).not.toHaveBeenCalledWith(input)
129
+ await act(async () => vi.advanceTimersByTime(100))
130
+ expect(second.send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toEqual([[input]])
131
+ })
132
+
133
+ it('sends the initial command independently in each terminal tab', async () => {
134
+ vi.useFakeTimers()
135
+ await act(async () => {
136
+ root.render(<>
137
+ <LocalTerminalTab createSession={async () => ({ wsUrl: 'ws://localhost/first' })} initialCommand="aws sso login" />
138
+ <LocalTerminalTab createSession={async () => ({ wsUrl: 'ws://localhost/second' })} initialCommand="aws sso login" />
139
+ </>)
140
+ })
141
+ expect(Socket.instances).toHaveLength(2)
142
+ await act(async () => Socket.instances.forEach(socket => socket.onopen?.()))
143
+ await act(async () => vi.advanceTimersByTime(300))
144
+ const input = JSON.stringify({ type: 'input', data: 'aws sso login\n' })
145
+ for (const socket of Socket.instances) {
146
+ expect(socket.send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toEqual([[input]])
147
+ }
148
+ })
149
+
150
+ it('keeps a blocked reconnect intact until the host permits it', async () => {
151
+ vi.useFakeTimers()
152
+ let allowed = true
153
+ const createSession = vi.fn(async () => ({ wsUrl: 'ws://localhost' }))
154
+ const onSessionInfo = vi.fn()
155
+ await render({ createSession, canConnect: () => allowed, initialCommand: 'auth-command', onSessionInfo })
156
+ await act(async () => Socket.instances[0].onopen?.())
157
+ await act(async () => vi.advanceTimersByTime(300))
158
+ await act(async () => Socket.instances[0].close())
159
+ const terminal = element.querySelector('div.absolute')
160
+ expect(terminal).not.toBeNull()
161
+ const metadataCalls = onSessionInfo.mock.calls.length
162
+ allowed = false
163
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
164
+ expect(createSession).toHaveBeenCalledTimes(1)
165
+ expect(onSessionInfo).toHaveBeenCalledTimes(metadataCalls)
166
+ expect(element.contains(terminal)).toBe(true)
167
+ allowed = true
168
+ await act(async () => element.querySelector<HTMLButtonElement>('button')!.click())
169
+ await act(async () => Socket.instances[1].onopen?.())
170
+ await act(async () => vi.advanceTimersByTime(300))
171
+ expect(createSession).toHaveBeenCalledTimes(2)
172
+ expect(Socket.instances.flatMap(s => s.send.mock.calls).filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
173
+ })
174
+
175
+ it.each(['local', 'pod'])('allows Retry after a %s terminal creation error', async kind => {
176
+ const createSession = vi.fn()
177
+ .mockRejectedValueOnce(new Error('creation failed'))
178
+ .mockResolvedValueOnce({ wsUrl: 'ws://localhost' })
179
+ await act(async () => root.render(kind === 'local'
180
+ ? <LocalTerminalTab createSession={createSession} />
181
+ : <TerminalTab namespace="default" podName="pod" containerName="app" containers={['app']} createSession={createSession} />))
182
+ expect(element.textContent).toContain('creation failed')
183
+ const retry = [...element.querySelectorAll('button')].find(button => button.textContent?.trim() === 'Retry')!
184
+ await act(async () => retry.click())
185
+ expect(createSession).toHaveBeenCalledTimes(2)
186
+ expect(Socket.instances).toHaveLength(1)
187
+ await act(async () => Socket.instances[0].onopen?.())
188
+ expect(element.textContent).not.toContain('creation failed')
189
+ })
@@ -1,4 +1,4 @@
1
- import { useEffect, useLayoutEffect, useRef, useCallback, useState } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useCallback, useState, type ReactNode } from 'react'
2
2
  import { Terminal as XTerm } from '@xterm/xterm'
3
3
  import { FitAddon } from '@xterm/addon-fit'
4
4
  import { WebLinksAddon } from '@xterm/addon-web-links'
@@ -9,31 +9,52 @@ import { setupTerminalClipboard, copyTerminalSelection } from './terminalClipboa
9
9
  import { TerminalClipboardToolbar } from './TerminalClipboardToolbar'
10
10
  import { useMultilinePasteConfirm } from './useMultilinePasteConfirm'
11
11
 
12
+ export interface LocalTerminalSessionInfo {
13
+ context: string
14
+ kubeconfigIsolated: boolean
15
+ }
16
+
12
17
  export interface LocalTerminalTabProps {
13
18
  isActive?: boolean
14
19
  /** Returns the WebSocket URL for the local terminal session */
15
20
  createSession: () => Promise<{ wsUrl: string }>
16
- /** Command to auto-execute after the terminal connects */
21
+ /** Command to send once per mounted terminal after a connection opens */
17
22
  initialCommand?: string
23
+ canConnect?: () => boolean
24
+ onConnectionError?: () => void
25
+ onSessionInfo?: (info: LocalTerminalSessionInfo | null) => void
26
+ toolbarExtra?: ReactNode
18
27
  }
19
28
 
20
29
  export function LocalTerminalTab({
21
30
  isActive = true,
22
31
  createSession,
23
32
  initialCommand,
33
+ canConnect,
34
+ onConnectionError,
35
+ onSessionInfo,
36
+ toolbarExtra,
24
37
  }: LocalTerminalTabProps) {
25
38
  const terminalRef = useRef<HTMLDivElement>(null)
26
39
  const xtermRef = useRef<XTerm | null>(null)
27
40
  const fitAddonRef = useRef<FitAddon | null>(null)
28
41
  const wsRef = useRef<WebSocket | null>(null)
29
42
  const cleanupRef = useRef<(() => void) | undefined>(undefined)
30
- const cancelledRef = useRef(false)
43
+ const connectionAttemptRef = useRef(0)
44
+ const initialCommandSentRef = useRef(false)
31
45
  const createSessionRef = useRef(createSession)
46
+ const onSessionInfoRef = useRef(onSessionInfo)
47
+ const canConnectRef = useRef(canConnect)
48
+ const onConnectionErrorRef = useRef(onConnectionError)
32
49
  useLayoutEffect(() => { createSessionRef.current = createSession }, [createSession])
50
+ useLayoutEffect(() => { onSessionInfoRef.current = onSessionInfo }, [onSessionInfo])
51
+ useLayoutEffect(() => { canConnectRef.current = canConnect }, [canConnect])
52
+ useLayoutEffect(() => { onConnectionErrorRef.current = onConnectionError }, [onConnectionError])
33
53
  const [isConnected, setIsConnected] = useState(false)
34
54
  const [isConnecting, setIsConnecting] = useState(true)
35
55
  const [error, setError] = useState<string | null>(null)
36
56
  const [hasSelection, setHasSelection] = useState(false)
57
+ const connectAllowed = canConnect?.() !== false
37
58
  const { confirmPaste, pasteDialog } = useMultilinePasteConfirm()
38
59
 
39
60
  const handleCopy = useCallback(() => {
@@ -43,7 +64,13 @@ export function LocalTerminalTab({
43
64
  const connect = useCallback(() => {
44
65
  if (!terminalRef.current) return
45
66
 
46
- cancelledRef.current = false
67
+ if (canConnectRef.current && !canConnectRef.current()) {
68
+ setIsConnecting(false)
69
+ return
70
+ }
71
+
72
+ const attempt = ++connectionAttemptRef.current
73
+ onSessionInfoRef.current?.(null)
47
74
  setIsConnecting(true)
48
75
  setError(null)
49
76
 
@@ -145,34 +172,39 @@ export function LocalTerminalTab({
145
172
 
146
173
  createSessionRef.current()
147
174
  .then(({ wsUrl }) => {
148
- if (cancelledRef.current) return
175
+ if (attempt !== connectionAttemptRef.current) return
149
176
  const ws = new WebSocket(wsUrl)
150
177
  wsRef.current = ws
151
178
 
152
179
  ws.onopen = () => {
180
+ if (attempt !== connectionAttemptRef.current) return
153
181
  setIsConnected(true)
154
182
  setIsConnecting(false)
155
183
  doFit(ws)
156
184
  xterm.focus()
157
- if (initialCommand) {
185
+ if (initialCommand && !initialCommandSentRef.current) {
158
186
  // Small delay to let the shell prompt initialize
159
187
  setTimeout(() => {
160
- if (ws.readyState === WebSocket.OPEN) {
188
+ if (attempt === connectionAttemptRef.current && !initialCommandSentRef.current && ws.readyState === WebSocket.OPEN) {
161
189
  ws.send(JSON.stringify({ type: 'input', data: initialCommand + '\n' }))
190
+ initialCommandSentRef.current = true
162
191
  }
163
192
  }, 300)
164
193
  }
165
194
  }
166
195
 
167
196
  ws.onmessage = (event) => {
168
- let msg: Record<string, string> | null = null
197
+ if (attempt !== connectionAttemptRef.current) return
198
+ let msg: { type: 'output' | 'error' | 'exit'; data?: string } | ({ type: 'session' } & LocalTerminalSessionInfo)
169
199
  try {
170
- msg = JSON.parse(event.data as string) as Record<string, string>
200
+ msg = JSON.parse(event.data as string)
171
201
  } catch {
172
202
  xterm.write(event.data as string)
173
203
  return
174
204
  }
175
- if (msg.type === 'output' && msg.data) {
205
+ if (msg.type === 'session') {
206
+ onSessionInfoRef.current?.({ context: msg.context, kubeconfigIsolated: msg.kubeconfigIsolated })
207
+ } else if (msg.type === 'output' && msg.data) {
176
208
  xterm.write(msg.data)
177
209
  } else if (msg.type === 'exit') {
178
210
  xterm.write('\r\n\x1b[2m[Process exited]\x1b[0m\r\n')
@@ -183,12 +215,15 @@ export function LocalTerminalTab({
183
215
  }
184
216
 
185
217
  ws.onerror = () => {
218
+ if (attempt !== connectionAttemptRef.current) return
219
+ onConnectionErrorRef.current?.()
186
220
  setError((prev) => prev || 'Connection error')
187
221
  setIsConnected(false)
188
222
  setIsConnecting(false)
189
223
  }
190
224
 
191
225
  ws.onclose = () => {
226
+ if (attempt !== connectionAttemptRef.current) return
192
227
  setIsConnected(false)
193
228
  setIsConnecting(false)
194
229
  xterm.write('\r\n\x1b[31mConnection closed\x1b[0m\r\n')
@@ -201,6 +236,7 @@ export function LocalTerminalTab({
201
236
  })
202
237
  })
203
238
  .catch((err) => {
239
+ if (attempt !== connectionAttemptRef.current) return
204
240
  setError(err instanceof Error ? err.message : 'Failed to connect')
205
241
  setIsConnecting(false)
206
242
  })
@@ -209,7 +245,7 @@ export function LocalTerminalTab({
209
245
  useEffect(() => {
210
246
  connect()
211
247
  return () => {
212
- cancelledRef.current = true
248
+ connectionAttemptRef.current++
213
249
  cleanupRef.current?.()
214
250
  wsRef.current?.close()
215
251
  xtermRef.current?.dispose()
@@ -232,16 +268,18 @@ export function LocalTerminalTab({
232
268
  <span
233
269
  title={isConnected ? 'Connected' : isConnecting ? 'Connecting...' : 'Disconnected'}
234
270
  className={clsx(
235
- 'w-2 h-2 rounded-full',
271
+ 'w-2 h-2 shrink-0 rounded-full',
236
272
  isConnected ? 'bg-green-500' : isConnecting ? 'bg-yellow-500 animate-pulse' : 'bg-red-500'
237
273
  )}
238
274
  />
239
- <span className="text-xs text-theme-text-tertiary">Local Terminal</span>
275
+ <span className="shrink-0 text-xs text-theme-text-tertiary">Local Terminal</span>
276
+ {toolbarExtra}
240
277
 
241
278
  {!isConnected && !isConnecting && (
242
279
  <button
243
280
  onClick={connect}
244
- className="flex items-center gap-1 px-2 py-0.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
281
+ disabled={!connectAllowed}
282
+ className="shrink-0 whitespace-nowrap flex items-center gap-1 px-2 py-0.5 text-xs text-theme-text-tertiary enabled:hover:text-theme-text-primary enabled:hover:bg-theme-elevated disabled:opacity-50 disabled:cursor-not-allowed rounded"
245
283
  >
246
284
  <RefreshCw className="w-3 h-3" />
247
285
  Reconnect
@@ -252,20 +290,20 @@ export function LocalTerminalTab({
252
290
  </div>
253
291
 
254
292
  {/* Terminal or error */}
255
- {error ? (
293
+ <div ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
294
+ {error && (
256
295
  <div key="error" className="absolute top-8 left-0 right-0 bottom-0 flex flex-col items-center justify-center p-4 text-center bg-slate-900">
257
296
  <div className="text-red-400 mb-2 text-sm">Failed to connect</div>
258
297
  <div className="text-xs text-theme-text-disabled mb-3">{error}</div>
259
298
  <button
260
299
  onClick={connect}
261
- className="flex items-center gap-2 px-3 py-1.5 btn-brand text-xs rounded"
300
+ disabled={!connectAllowed}
301
+ className="flex items-center gap-2 px-3 py-1.5 btn-brand text-xs rounded disabled:opacity-50 disabled:cursor-not-allowed"
262
302
  >
263
303
  <RefreshCw className="w-3 h-3" />
264
304
  Retry
265
305
  </button>
266
306
  </div>
267
- ) : (
268
- <div key="terminal" ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
269
307
  )}
270
308
  {pasteDialog}
271
309
  </div>
@@ -298,8 +298,8 @@ export function TerminalTab({
298
298
  {!error && <TerminalClipboardToolbar hasSelection={hasSelection} onCopy={handleCopy} />}
299
299
  </div>
300
300
 
301
- {/* Terminal or error — key forces xterm canvas unmount/remount on toggle */}
302
- {error ? (
301
+ <div ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
302
+ {error && (
303
303
  <div key="error" className="absolute top-8 left-0 right-0 bottom-0 flex flex-col items-center justify-center p-4 text-center bg-slate-900">
304
304
  {errorType === 'shell_not_found' ? (
305
305
  <>
@@ -347,8 +347,6 @@ export function TerminalTab({
347
347
  </>
348
348
  )}
349
349
  </div>
350
- ) : (
351
- <div key="terminal" ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
352
350
  )}
353
351
  {pasteDialog}
354
352
  </div>
@@ -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
- ? [issue.restart_count ? `${issue.restart_count} restart${issue.restart_count === 1 ? '' : 's'}` : null, issue.last_terminated_reason ? `last exit: ${issue.last_terminated_reason}` : null]
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 default dark. See log-palette.ts for why the viewer is palette-driven
158
+ // else defaultDark (the host app's theme, dark if not given). See
159
+ // log-palette.ts for why the viewer is palette-driven
152
160
  // instead of theme-token-driven.
153
161
  const [isDark, setIsDark] = useState<boolean>(() => {
154
162
  if (typeof forceDark === 'boolean') return forceDark
@@ -157,13 +165,26 @@ export function LogCore({
157
165
  if (v === 'false') return false
158
166
  if (v === 'true') return true
159
167
  } catch {}
160
- return true
168
+ return defaultDark
161
169
  })
162
170
  useEffect(() => {
163
171
  if (typeof forceDark === 'boolean') {
164
172
  setIsDark(forceDark)
165
173
  }
166
174
  }, [forceDark])
175
+ // Re-resolve whenever the host's hint changes, including when a forceDark pin
176
+ // is lifted: the user's saved pick wins, otherwise the host default.
177
+ useEffect(() => {
178
+ if (typeof forceDark === 'boolean') return
179
+ try {
180
+ const v = localStorage.getItem('radar-logs-dark')
181
+ if (v === 'true' || v === 'false') {
182
+ setIsDark(v === 'true')
183
+ return
184
+ }
185
+ } catch {}
186
+ setIsDark(defaultDark)
187
+ }, [defaultDark, forceDark])
167
188
  const palette = useMemo(() => getLogPalette(isDark), [isDark])
168
189
  const toggleDark = useCallback(() => {
169
190
  if (themeLocked) return