@skyhook-io/k8s-ui 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/components/dock/BottomDock.tsx +14 -15
- package/src/components/dock/DockContext.tsx +40 -1
- package/src/components/dock/LocalTerminalTab.tsx +241 -0
- package/src/components/dock/NodeTerminalTab.tsx +129 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
- package/src/components/logs/LogCore.tsx +30 -4
- package/src/components/logs/StructuredLogLine.tsx +168 -0
- package/src/components/logs/index.ts +1 -1
- package/src/components/logs/useLogBuffer.ts +16 -10
- package/src/components/resources/ResourcesView.tsx +136 -24
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
- package/src/components/resources/renderers/PodRenderer.tsx +164 -8
- package/src/components/resources/renderers/contour-cells.tsx +48 -0
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/renderers/trivy-shared.tsx +9 -25
- package/src/components/resources/resource-utils-contour.ts +34 -0
- package/src/components/resources/resource-utils.ts +91 -0
- package/src/components/shared/ResourceActionsBar.tsx +135 -2
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -5
- package/src/components/timeline/shared.tsx +8 -10
- package/src/components/topology/K8sResourceNode.tsx +1 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
- package/src/components/topology/layout.ts +1 -0
- package/src/components/topology/topology.css +1 -0
- package/src/components/ui/CodeViewer.tsx +1 -2
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
- package/src/components/ui/Toast.tsx +5 -5
- package/src/components/ui/drawer-components.tsx +28 -22
- package/src/components/workload/WorkloadView.tsx +11 -3
- package/src/theme/index.ts +3 -0
- package/src/theme/tailwind-theme.css +61 -0
- package/src/theme/variables.css +121 -0
- package/src/types/core.ts +12 -0
- package/src/utils/animation.ts +1 -1
- package/src/utils/badge-colors.ts +158 -123
- package/src/utils/log-format.ts +127 -35
- package/src/utils/resource-hierarchy.ts +5 -0
- package/src/utils/resource-icons.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
"./resources": "./src/components/resources/index.ts",
|
|
20
20
|
"./logs": "./src/components/logs/index.ts",
|
|
21
21
|
"./timeline": "./src/components/timeline/index.ts",
|
|
22
|
-
"./topology": "./src/components/topology/index.ts"
|
|
22
|
+
"./topology": "./src/components/topology/index.ts",
|
|
23
|
+
"./theme/variables.css": "./src/theme/variables.css",
|
|
24
|
+
"./theme/tailwind-theme.css": "./src/theme/tailwind-theme.css",
|
|
25
|
+
"./theme": "./src/theme/index.ts",
|
|
26
|
+
"./topology.css": "./src/components/topology/topology.css"
|
|
23
27
|
},
|
|
24
28
|
"scripts": {
|
|
25
29
|
"tsc": "tsc --noEmit",
|
|
@@ -86,8 +86,8 @@ export function BottomDock({ renderTabContent, leftOffset = 0 }: BottomDockProps
|
|
|
86
86
|
|
|
87
87
|
return (
|
|
88
88
|
<div
|
|
89
|
-
className="fixed bottom-0 right-0 bg-theme-base border-t border-theme-border flex flex-col z-40"
|
|
90
|
-
style={{ height: effectiveHeight, left: leftOffset, transition: `height ${DURATION_DOCK}ms
|
|
89
|
+
className="fixed bottom-0 right-0 bg-theme-base border-t border-theme-border flex flex-col z-40 overflow-hidden"
|
|
90
|
+
style={{ height: effectiveHeight, left: leftOffset, transition: `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1), left ${DURATION_DOCK}ms ease-out` }}
|
|
91
91
|
>
|
|
92
92
|
{isExpanded && !isMaximized && (
|
|
93
93
|
<div
|
|
@@ -149,18 +149,16 @@ export function BottomDock({ renderTabContent, leftOffset = 0 }: BottomDockProps
|
|
|
149
149
|
</div>
|
|
150
150
|
</div>
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
</div>
|
|
163
|
-
)}
|
|
152
|
+
<div className="flex-1 overflow-hidden w-full relative">
|
|
153
|
+
{tabs.map(tab => (
|
|
154
|
+
<div
|
|
155
|
+
key={tab.id}
|
|
156
|
+
className={tab.id === activeTabId ? 'absolute inset-0' : 'absolute inset-0 invisible'}
|
|
157
|
+
>
|
|
158
|
+
{renderTabContent(tab, tab.id === activeTabId)}
|
|
159
|
+
</div>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
164
162
|
</div>
|
|
165
163
|
)
|
|
166
164
|
}
|
|
@@ -176,7 +174,8 @@ function TabButton({
|
|
|
176
174
|
onSelect: () => void
|
|
177
175
|
onClose: () => void
|
|
178
176
|
}) {
|
|
179
|
-
const Icon = tab.type === 'terminal'
|
|
177
|
+
const Icon = tab.type === 'terminal' || tab.type === 'node-terminal' || tab.type === 'local-terminal'
|
|
178
|
+
? Terminal : tab.type === 'workload-logs' ? Layers : FileText
|
|
180
179
|
|
|
181
180
|
return (
|
|
182
181
|
<div
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react'
|
|
2
2
|
|
|
3
|
-
export type DockTabType = 'terminal' | 'logs' | 'workload-logs'
|
|
3
|
+
export type DockTabType = 'terminal' | 'logs' | 'workload-logs' | 'node-terminal' | 'local-terminal'
|
|
4
4
|
|
|
5
5
|
export interface DockTab {
|
|
6
6
|
id: string
|
|
@@ -18,6 +18,8 @@ export interface DockTab {
|
|
|
18
18
|
// Workload logs props
|
|
19
19
|
workloadKind?: string
|
|
20
20
|
workloadName?: string
|
|
21
|
+
// Node terminal props
|
|
22
|
+
nodeName?: string
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface DockContextValue {
|
|
@@ -54,6 +56,12 @@ export function DockProvider({ children }: { children: ReactNode }) {
|
|
|
54
56
|
t.workloadKind === tabData.workloadKind &&
|
|
55
57
|
t.workloadName === tabData.workloadName
|
|
56
58
|
}
|
|
59
|
+
if (t.type === 'node-terminal') {
|
|
60
|
+
return t.nodeName === tabData.nodeName
|
|
61
|
+
}
|
|
62
|
+
if (t.type === 'local-terminal') {
|
|
63
|
+
return false // Allow multiple local terminals
|
|
64
|
+
}
|
|
57
65
|
return t.namespace === tabData.namespace &&
|
|
58
66
|
t.podName === tabData.podName &&
|
|
59
67
|
t.containerName === tabData.containerName
|
|
@@ -210,3 +218,34 @@ export function useOpenWorkloadLogs() {
|
|
|
210
218
|
})
|
|
211
219
|
}
|
|
212
220
|
}
|
|
221
|
+
|
|
222
|
+
export function useOpenNodeTerminal() {
|
|
223
|
+
const { addTab } = useDock()
|
|
224
|
+
|
|
225
|
+
return (opts: {
|
|
226
|
+
nodeName: string
|
|
227
|
+
orgId?: string
|
|
228
|
+
clusterId?: string
|
|
229
|
+
clusterName?: string
|
|
230
|
+
}) => {
|
|
231
|
+
addTab({
|
|
232
|
+
type: 'node-terminal',
|
|
233
|
+
title: `node: ${opts.nodeName}`,
|
|
234
|
+
nodeName: opts.nodeName,
|
|
235
|
+
orgId: opts.orgId,
|
|
236
|
+
clusterId: opts.clusterId,
|
|
237
|
+
clusterName: opts.clusterName,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function useOpenLocalTerminal() {
|
|
243
|
+
const { addTab } = useDock()
|
|
244
|
+
|
|
245
|
+
return () => {
|
|
246
|
+
addTab({
|
|
247
|
+
type: 'local-terminal',
|
|
248
|
+
title: 'Terminal',
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useCallback, useState } from 'react'
|
|
2
|
+
import { Terminal as XTerm } from '@xterm/xterm'
|
|
3
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
4
|
+
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
5
|
+
import '@xterm/xterm/css/xterm.css'
|
|
6
|
+
import { RefreshCw } from 'lucide-react'
|
|
7
|
+
import { clsx } from 'clsx'
|
|
8
|
+
|
|
9
|
+
export interface LocalTerminalTabProps {
|
|
10
|
+
isActive?: boolean
|
|
11
|
+
/** Returns the WebSocket URL for the local terminal session */
|
|
12
|
+
createSession: () => Promise<{ wsUrl: string }>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function LocalTerminalTab({
|
|
16
|
+
isActive = true,
|
|
17
|
+
createSession,
|
|
18
|
+
}: LocalTerminalTabProps) {
|
|
19
|
+
const terminalRef = useRef<HTMLDivElement>(null)
|
|
20
|
+
const xtermRef = useRef<XTerm | null>(null)
|
|
21
|
+
const fitAddonRef = useRef<FitAddon | null>(null)
|
|
22
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
23
|
+
const cleanupRef = useRef<(() => void) | undefined>(undefined)
|
|
24
|
+
const cancelledRef = useRef(false)
|
|
25
|
+
const createSessionRef = useRef(createSession)
|
|
26
|
+
useLayoutEffect(() => { createSessionRef.current = createSession }, [createSession])
|
|
27
|
+
const [isConnected, setIsConnected] = useState(false)
|
|
28
|
+
const [isConnecting, setIsConnecting] = useState(true)
|
|
29
|
+
const [error, setError] = useState<string | null>(null)
|
|
30
|
+
|
|
31
|
+
const connect = useCallback(() => {
|
|
32
|
+
if (!terminalRef.current) return
|
|
33
|
+
|
|
34
|
+
cancelledRef.current = false
|
|
35
|
+
setIsConnecting(true)
|
|
36
|
+
setError(null)
|
|
37
|
+
|
|
38
|
+
// Clean up existing terminal and connection
|
|
39
|
+
cleanupRef.current?.()
|
|
40
|
+
xtermRef.current?.dispose()
|
|
41
|
+
wsRef.current?.close()
|
|
42
|
+
|
|
43
|
+
const xterm = new XTerm({
|
|
44
|
+
cursorBlink: true,
|
|
45
|
+
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
|
|
46
|
+
fontSize: 13,
|
|
47
|
+
lineHeight: 1.2,
|
|
48
|
+
theme: {
|
|
49
|
+
background: '#0f172a',
|
|
50
|
+
foreground: '#e2e8f0',
|
|
51
|
+
cursor: '#60a5fa',
|
|
52
|
+
cursorAccent: '#0f172a',
|
|
53
|
+
selectionBackground: '#3b82f680',
|
|
54
|
+
black: '#1e293b',
|
|
55
|
+
red: '#f87171',
|
|
56
|
+
green: '#4ade80',
|
|
57
|
+
yellow: '#facc15',
|
|
58
|
+
blue: '#60a5fa',
|
|
59
|
+
magenta: '#c084fc',
|
|
60
|
+
cyan: '#22d3ee',
|
|
61
|
+
white: '#f1f5f9',
|
|
62
|
+
brightBlack: '#475569',
|
|
63
|
+
brightRed: '#fca5a5',
|
|
64
|
+
brightGreen: '#86efac',
|
|
65
|
+
brightYellow: '#fde047',
|
|
66
|
+
brightBlue: '#93c5fd',
|
|
67
|
+
brightMagenta: '#d8b4fe',
|
|
68
|
+
brightCyan: '#67e8f9',
|
|
69
|
+
brightWhite: '#f8fafc',
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const fitAddon = new FitAddon()
|
|
74
|
+
const webLinksAddon = new WebLinksAddon()
|
|
75
|
+
xterm.loadAddon(fitAddon)
|
|
76
|
+
xterm.loadAddon(webLinksAddon)
|
|
77
|
+
xterm.open(terminalRef.current)
|
|
78
|
+
|
|
79
|
+
xtermRef.current = xterm
|
|
80
|
+
fitAddonRef.current = fitAddon
|
|
81
|
+
|
|
82
|
+
const doFit = (ws?: WebSocket) => {
|
|
83
|
+
const dims = fitAddon.proposeDimensions()
|
|
84
|
+
if (dims) xterm.resize(dims.cols, dims.rows)
|
|
85
|
+
const conn = ws ?? wsRef.current
|
|
86
|
+
if (conn?.readyState === WebSocket.OPEN) {
|
|
87
|
+
conn.send(JSON.stringify({ type: 'resize', rows: xterm.rows, cols: xterm.cols }))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
requestAnimationFrame(() => {
|
|
92
|
+
doFit()
|
|
93
|
+
setTimeout(doFit, 100)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
createSessionRef.current()
|
|
97
|
+
.then(({ wsUrl }) => {
|
|
98
|
+
if (cancelledRef.current) return
|
|
99
|
+
const ws = new WebSocket(wsUrl)
|
|
100
|
+
wsRef.current = ws
|
|
101
|
+
|
|
102
|
+
ws.onopen = () => {
|
|
103
|
+
setIsConnected(true)
|
|
104
|
+
setIsConnecting(false)
|
|
105
|
+
doFit(ws)
|
|
106
|
+
xterm.focus()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
ws.onmessage = (event) => {
|
|
110
|
+
let msg: Record<string, string> | null = null
|
|
111
|
+
try {
|
|
112
|
+
msg = JSON.parse(event.data as string) as Record<string, string>
|
|
113
|
+
} catch {
|
|
114
|
+
xterm.write(event.data as string)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (msg.type === 'output' && msg.data) {
|
|
118
|
+
xterm.write(msg.data)
|
|
119
|
+
} else if (msg.type === 'exit') {
|
|
120
|
+
xterm.write('\r\n\x1b[2m[Process exited]\x1b[0m\r\n')
|
|
121
|
+
} else if (msg.type === 'error' && msg.data) {
|
|
122
|
+
setError(msg.data)
|
|
123
|
+
setIsConnected(false)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ws.onerror = () => {
|
|
128
|
+
setError((prev) => prev || 'Connection error')
|
|
129
|
+
setIsConnected(false)
|
|
130
|
+
setIsConnecting(false)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ws.onclose = () => {
|
|
134
|
+
setIsConnected(false)
|
|
135
|
+
setIsConnecting(false)
|
|
136
|
+
xterm.write('\r\n\x1b[31mConnection closed\x1b[0m\r\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
xterm.onData((data: string) => {
|
|
140
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
141
|
+
ws.send(JSON.stringify({ type: 'input', data }))
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// Debounced resize
|
|
146
|
+
let resizeTimeout: ReturnType<typeof setTimeout> | null = null
|
|
147
|
+
let lastWidth = 0
|
|
148
|
+
let lastHeight = 0
|
|
149
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
150
|
+
const entry = entries[0]
|
|
151
|
+
if (!entry) return
|
|
152
|
+
const { width, height } = entry.contentRect
|
|
153
|
+
if (Math.abs(width - lastWidth) < 5 && Math.abs(height - lastHeight) < 5) return
|
|
154
|
+
lastWidth = width
|
|
155
|
+
lastHeight = height
|
|
156
|
+
if (resizeTimeout) clearTimeout(resizeTimeout)
|
|
157
|
+
resizeTimeout = setTimeout(() => {
|
|
158
|
+
if (fitAddonRef.current && xtermRef.current) {
|
|
159
|
+
const dims = fitAddonRef.current.proposeDimensions()
|
|
160
|
+
if (dims) xtermRef.current.resize(dims.cols, dims.rows)
|
|
161
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
162
|
+
ws.send(JSON.stringify({ type: 'resize', rows: xtermRef.current.rows, cols: xtermRef.current.cols }))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}, 100)
|
|
166
|
+
})
|
|
167
|
+
if (terminalRef.current) {
|
|
168
|
+
resizeObserver.observe(terminalRef.current)
|
|
169
|
+
cleanupRef.current = () => resizeObserver.disconnect()
|
|
170
|
+
} else {
|
|
171
|
+
resizeObserver.disconnect()
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
.catch((err) => {
|
|
175
|
+
setError(err instanceof Error ? err.message : 'Failed to connect')
|
|
176
|
+
setIsConnecting(false)
|
|
177
|
+
})
|
|
178
|
+
}, [])
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
connect()
|
|
182
|
+
return () => {
|
|
183
|
+
cancelledRef.current = true
|
|
184
|
+
cleanupRef.current?.()
|
|
185
|
+
wsRef.current?.close()
|
|
186
|
+
xtermRef.current?.dispose()
|
|
187
|
+
}
|
|
188
|
+
}, [connect])
|
|
189
|
+
|
|
190
|
+
// Refit when tab becomes active
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (isActive && fitAddonRef.current && xtermRef.current) {
|
|
193
|
+
const dims = fitAddonRef.current.proposeDimensions()
|
|
194
|
+
if (dims) xtermRef.current.resize(dims.cols, dims.rows)
|
|
195
|
+
xtermRef.current.focus()
|
|
196
|
+
}
|
|
197
|
+
}, [isActive])
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div className="relative h-full w-full bg-theme-base overflow-hidden">
|
|
201
|
+
{/* Mini toolbar */}
|
|
202
|
+
<div className="h-8 flex items-center gap-2 px-2 bg-theme-surface/50 border-b border-theme-border/50">
|
|
203
|
+
<span
|
|
204
|
+
title={isConnected ? 'Connected' : isConnecting ? 'Connecting...' : 'Disconnected'}
|
|
205
|
+
className={clsx(
|
|
206
|
+
'w-2 h-2 rounded-full',
|
|
207
|
+
isConnected ? 'bg-green-500' : isConnecting ? 'bg-yellow-500 animate-pulse' : 'bg-red-500'
|
|
208
|
+
)}
|
|
209
|
+
/>
|
|
210
|
+
<span className="text-xs text-theme-text-tertiary">Local Terminal</span>
|
|
211
|
+
|
|
212
|
+
{!isConnected && !isConnecting && (
|
|
213
|
+
<button
|
|
214
|
+
onClick={connect}
|
|
215
|
+
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"
|
|
216
|
+
>
|
|
217
|
+
<RefreshCw className="w-3 h-3" />
|
|
218
|
+
Reconnect
|
|
219
|
+
</button>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{/* Terminal or error */}
|
|
224
|
+
{error ? (
|
|
225
|
+
<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">
|
|
226
|
+
<div className="text-red-400 mb-2 text-sm">Failed to connect</div>
|
|
227
|
+
<div className="text-xs text-theme-text-disabled mb-3">{error}</div>
|
|
228
|
+
<button
|
|
229
|
+
onClick={connect}
|
|
230
|
+
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
|
|
231
|
+
>
|
|
232
|
+
<RefreshCw className="w-3 h-3" />
|
|
233
|
+
Retry
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
) : (
|
|
237
|
+
<div key="terminal" ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
2
|
+
import { Loader2, AlertCircle, RefreshCw } from 'lucide-react'
|
|
3
|
+
import { TerminalTab } from './TerminalTab'
|
|
4
|
+
|
|
5
|
+
export interface NodeTerminalTabProps {
|
|
6
|
+
nodeName: string
|
|
7
|
+
isActive?: boolean
|
|
8
|
+
/** Create a debug pod on the node, returns pod coordinates for exec */
|
|
9
|
+
createNodeDebugPod: (nodeName: string) => Promise<{
|
|
10
|
+
podName: string
|
|
11
|
+
namespace: string
|
|
12
|
+
containerName: string
|
|
13
|
+
}>
|
|
14
|
+
/** Clean up debug pod(s) for this node */
|
|
15
|
+
cleanupNodeDebugPod: (nodeName: string) => Promise<void>
|
|
16
|
+
/** Return WebSocket URL for exec into a pod container */
|
|
17
|
+
createSession: (namespace: string, podName: string, containerName: string) => Promise<{ wsUrl: string }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function NodeTerminalTab({
|
|
21
|
+
nodeName,
|
|
22
|
+
isActive,
|
|
23
|
+
createNodeDebugPod,
|
|
24
|
+
cleanupNodeDebugPod,
|
|
25
|
+
createSession,
|
|
26
|
+
}: NodeTerminalTabProps) {
|
|
27
|
+
const [debugPod, setDebugPod] = useState<{
|
|
28
|
+
podName: string
|
|
29
|
+
namespace: string
|
|
30
|
+
containerName: string
|
|
31
|
+
} | null>(null)
|
|
32
|
+
const [error, setError] = useState<string | null>(null)
|
|
33
|
+
const [isCreating, setIsCreating] = useState(true)
|
|
34
|
+
const cleanupDoneRef = useRef(false)
|
|
35
|
+
// Stable refs for callbacks
|
|
36
|
+
const createNodeDebugPodRef = useRef(createNodeDebugPod)
|
|
37
|
+
const cleanupNodeDebugPodRef = useRef(cleanupNodeDebugPod)
|
|
38
|
+
const createSessionRef = useRef(createSession)
|
|
39
|
+
useEffect(() => { createNodeDebugPodRef.current = createNodeDebugPod }, [createNodeDebugPod])
|
|
40
|
+
useEffect(() => { cleanupNodeDebugPodRef.current = cleanupNodeDebugPod }, [cleanupNodeDebugPod])
|
|
41
|
+
useEffect(() => { createSessionRef.current = createSession }, [createSession])
|
|
42
|
+
|
|
43
|
+
const createPod = useCallback(async () => {
|
|
44
|
+
cleanupDoneRef.current = false
|
|
45
|
+
setIsCreating(true)
|
|
46
|
+
setError(null)
|
|
47
|
+
try {
|
|
48
|
+
const result = await createNodeDebugPodRef.current(nodeName)
|
|
49
|
+
setDebugPod(result)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setError(err instanceof Error ? err.message : 'Failed to create debug pod')
|
|
52
|
+
} finally {
|
|
53
|
+
setIsCreating(false)
|
|
54
|
+
}
|
|
55
|
+
}, [nodeName])
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
createPod()
|
|
59
|
+
return () => {
|
|
60
|
+
if (!cleanupDoneRef.current) {
|
|
61
|
+
cleanupDoneRef.current = true
|
|
62
|
+
cleanupNodeDebugPodRef.current(nodeName).catch((err) => {
|
|
63
|
+
console.warn('[NodeTerminal] Cleanup on unmount failed:', err)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}, [nodeName, createPod])
|
|
68
|
+
|
|
69
|
+
// Best-effort cleanup on page unload — uses keepalive so the browser
|
|
70
|
+
// does not cancel the request when the page navigates away.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const handleUnload = () => {
|
|
73
|
+
if (!cleanupDoneRef.current) {
|
|
74
|
+
cleanupDoneRef.current = true
|
|
75
|
+
cleanupNodeDebugPodRef.current(nodeName).catch(() => {})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
window.addEventListener('beforeunload', handleUnload)
|
|
79
|
+
return () => window.removeEventListener('beforeunload', handleUnload)
|
|
80
|
+
}, [nodeName])
|
|
81
|
+
|
|
82
|
+
if (isCreating) {
|
|
83
|
+
return (
|
|
84
|
+
<div className="h-full w-full flex flex-col items-center justify-center gap-3 bg-theme-base">
|
|
85
|
+
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
|
|
86
|
+
<div className="text-sm text-theme-text-secondary">
|
|
87
|
+
Creating debug pod on <span className="text-theme-text-primary font-medium">{nodeName}</span>...
|
|
88
|
+
</div>
|
|
89
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
90
|
+
This may take a moment while the pod starts
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (error) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="h-full w-full flex flex-col items-center justify-center gap-3 bg-theme-base p-4">
|
|
99
|
+
<AlertCircle className="w-6 h-6 text-red-400" />
|
|
100
|
+
<div className="text-sm text-red-400">Failed to create debug shell</div>
|
|
101
|
+
<div className="text-xs text-theme-text-tertiary text-center max-w-md break-all">{error}</div>
|
|
102
|
+
<button
|
|
103
|
+
onClick={createPod}
|
|
104
|
+
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
|
|
105
|
+
>
|
|
106
|
+
<RefreshCw className="w-3 h-3" />
|
|
107
|
+
Retry
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!debugPod) return null
|
|
114
|
+
|
|
115
|
+
// Wrap createSession to bind the debug pod's namespace/podName
|
|
116
|
+
const boundCreateSession = (containerName: string) =>
|
|
117
|
+
createSessionRef.current(debugPod.namespace, debugPod.podName, containerName)
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<TerminalTab
|
|
121
|
+
namespace={debugPod.namespace}
|
|
122
|
+
podName={debugPod.podName}
|
|
123
|
+
containerName={debugPod.containerName}
|
|
124
|
+
containers={[debugPod.containerName]}
|
|
125
|
+
isActive={isActive}
|
|
126
|
+
createSession={boundCreateSession}
|
|
127
|
+
/>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -326,7 +326,7 @@ export function TerminalTab({
|
|
|
326
326
|
)}
|
|
327
327
|
</div>
|
|
328
328
|
) : (
|
|
329
|
-
<div key="terminal" ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0" />
|
|
329
|
+
<div key="terminal" ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
|
|
330
330
|
)}
|
|
331
331
|
</div>
|
|
332
332
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { clsx } from 'clsx'
|
|
2
2
|
import { CheckCircle2, AlertCircle, Loader2, Pause, HelpCircle, XCircle } from 'lucide-react'
|
|
3
3
|
import type { GitOpsStatus, SyncStatus, GitOpsHealthStatus } from '../../types/gitops'
|
|
4
|
+
import { SEVERITY_BADGE_BORDERED, SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
4
5
|
|
|
5
6
|
interface GitOpsStatusBadgeProps {
|
|
6
7
|
status: GitOpsStatus
|
|
@@ -66,22 +67,12 @@ function getStatusIcon(status: GitOpsStatus) {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
function getStatusColorClass(status: GitOpsStatus): string {
|
|
69
|
-
if (status.suspended)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (status.sync === '
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (status.health === 'Degraded') {
|
|
76
|
-
return 'bg-red-500/20 text-red-400 border border-red-500/30'
|
|
77
|
-
}
|
|
78
|
-
if (status.sync === 'OutOfSync') {
|
|
79
|
-
return 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
|
80
|
-
}
|
|
81
|
-
if (status.sync === 'Reconciling' || status.health === 'Progressing') {
|
|
82
|
-
return 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
|
|
83
|
-
}
|
|
84
|
-
return 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
|
|
70
|
+
if (status.suspended) return SEVERITY_BADGE_BORDERED.warning
|
|
71
|
+
if (status.sync === 'Synced' && status.health === 'Healthy') return SEVERITY_BADGE_BORDERED.success
|
|
72
|
+
if (status.health === 'Degraded') return SEVERITY_BADGE_BORDERED.error
|
|
73
|
+
if (status.sync === 'OutOfSync') return SEVERITY_BADGE_BORDERED.warning
|
|
74
|
+
if (status.sync === 'Reconciling' || status.health === 'Progressing') return SEVERITY_BADGE_BORDERED.info
|
|
75
|
+
return SEVERITY_BADGE_BORDERED.neutral
|
|
85
76
|
}
|
|
86
77
|
|
|
87
78
|
function getStatusLabel(status: GitOpsStatus): string {
|
|
@@ -115,41 +106,17 @@ function HealthIndicator({ health }: HealthIndicatorProps) {
|
|
|
115
106
|
function getHealthInfo(health: GitOpsHealthStatus) {
|
|
116
107
|
switch (health) {
|
|
117
108
|
case 'Healthy':
|
|
118
|
-
return {
|
|
119
|
-
icon: CheckCircle2,
|
|
120
|
-
color: 'bg-green-500/10 text-green-400',
|
|
121
|
-
label: 'Healthy',
|
|
122
|
-
}
|
|
109
|
+
return { icon: CheckCircle2, color: SEVERITY_BADGE.success, label: 'Healthy' }
|
|
123
110
|
case 'Progressing':
|
|
124
|
-
return {
|
|
125
|
-
icon: Loader2,
|
|
126
|
-
color: 'bg-blue-500/10 text-blue-400',
|
|
127
|
-
label: 'Progressing',
|
|
128
|
-
}
|
|
111
|
+
return { icon: Loader2, color: SEVERITY_BADGE.info, label: 'Progressing' }
|
|
129
112
|
case 'Degraded':
|
|
130
|
-
return {
|
|
131
|
-
icon: XCircle,
|
|
132
|
-
color: 'bg-red-500/10 text-red-400',
|
|
133
|
-
label: 'Degraded',
|
|
134
|
-
}
|
|
113
|
+
return { icon: XCircle, color: SEVERITY_BADGE.error, label: 'Degraded' }
|
|
135
114
|
case 'Suspended':
|
|
136
|
-
return {
|
|
137
|
-
icon: Pause,
|
|
138
|
-
color: 'bg-yellow-500/10 text-yellow-400',
|
|
139
|
-
label: 'Suspended',
|
|
140
|
-
}
|
|
115
|
+
return { icon: Pause, color: SEVERITY_BADGE.warning, label: 'Suspended' }
|
|
141
116
|
case 'Missing':
|
|
142
|
-
return {
|
|
143
|
-
icon: AlertCircle,
|
|
144
|
-
color: 'bg-orange-500/10 text-orange-400',
|
|
145
|
-
label: 'Missing',
|
|
146
|
-
}
|
|
117
|
+
return { icon: AlertCircle, color: SEVERITY_BADGE.warning, label: 'Missing' }
|
|
147
118
|
default:
|
|
148
|
-
return {
|
|
149
|
-
icon: HelpCircle,
|
|
150
|
-
color: 'bg-gray-500/10 text-gray-400',
|
|
151
|
-
label: 'Unknown',
|
|
152
|
-
}
|
|
119
|
+
return { icon: HelpCircle, color: SEVERITY_BADGE.neutral, label: 'Unknown' }
|
|
153
120
|
}
|
|
154
121
|
}
|
|
155
122
|
|
|
@@ -159,7 +126,7 @@ function getHealthInfo(health: GitOpsHealthStatus) {
|
|
|
159
126
|
export function SyncStatusBadge({ sync, suspended }: { sync: SyncStatus; suspended?: boolean }) {
|
|
160
127
|
if (suspended) {
|
|
161
128
|
return (
|
|
162
|
-
<span className=
|
|
129
|
+
<span className={clsx('px-2 py-0.5 rounded text-xs font-medium inline-flex items-center gap-1', SEVERITY_BADGE_BORDERED.warning)}>
|
|
163
130
|
<Pause className="w-3 h-3" />
|
|
164
131
|
Suspended
|
|
165
132
|
</span>
|
|
@@ -180,29 +147,13 @@ export function SyncStatusBadge({ sync, suspended }: { sync: SyncStatus; suspend
|
|
|
180
147
|
function getSyncConfig(sync: SyncStatus) {
|
|
181
148
|
switch (sync) {
|
|
182
149
|
case 'Synced':
|
|
183
|
-
return {
|
|
184
|
-
icon: CheckCircle2,
|
|
185
|
-
color: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
|
186
|
-
label: 'Synced',
|
|
187
|
-
}
|
|
150
|
+
return { icon: CheckCircle2, color: SEVERITY_BADGE_BORDERED.success, label: 'Synced' }
|
|
188
151
|
case 'OutOfSync':
|
|
189
|
-
return {
|
|
190
|
-
icon: AlertCircle,
|
|
191
|
-
color: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
|
|
192
|
-
label: 'OutOfSync',
|
|
193
|
-
}
|
|
152
|
+
return { icon: AlertCircle, color: SEVERITY_BADGE_BORDERED.warning, label: 'OutOfSync' }
|
|
194
153
|
case 'Reconciling':
|
|
195
|
-
return {
|
|
196
|
-
icon: Loader2,
|
|
197
|
-
color: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
|
198
|
-
label: 'Syncing',
|
|
199
|
-
}
|
|
154
|
+
return { icon: Loader2, color: SEVERITY_BADGE_BORDERED.info, label: 'Syncing' }
|
|
200
155
|
default:
|
|
201
|
-
return {
|
|
202
|
-
icon: HelpCircle,
|
|
203
|
-
color: 'bg-gray-500/20 text-gray-400 border border-gray-500/30',
|
|
204
|
-
label: 'Unknown',
|
|
205
|
-
}
|
|
156
|
+
return { icon: HelpCircle, color: SEVERITY_BADGE_BORDERED.neutral, label: 'Unknown' }
|
|
206
157
|
}
|
|
207
158
|
}
|
|
208
159
|
|