@skyhook-io/k8s-ui 1.1.0 → 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.
Files changed (58) hide show
  1. package/package.json +8 -2
  2. package/src/components/dock/BottomDock.tsx +14 -15
  3. package/src/components/dock/DockContext.tsx +40 -1
  4. package/src/components/dock/LocalTerminalTab.tsx +241 -0
  5. package/src/components/dock/NodeTerminalTab.tsx +129 -0
  6. package/src/components/dock/TerminalTab.tsx +1 -1
  7. package/src/components/dock/index.ts +2 -0
  8. package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
  9. package/src/components/logs/LogCore.tsx +30 -4
  10. package/src/components/logs/StructuredLogLine.tsx +168 -0
  11. package/src/components/logs/index.ts +1 -1
  12. package/src/components/logs/useLogBuffer.ts +16 -10
  13. package/src/components/resources/ResourcesView.tsx +186 -67
  14. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
  15. package/src/components/resources/renderers/HPARenderer.tsx +2 -1
  16. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +2 -1
  18. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +7 -6
  20. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +2 -1
  21. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/PodRenderer.tsx +164 -8
  24. package/src/components/resources/renderers/VPARenderer.tsx +2 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +53 -55
  26. package/src/components/resources/renderers/contour-cells.tsx +48 -0
  27. package/src/components/resources/renderers/index.ts +2 -0
  28. package/src/components/resources/renderers/trivy-shared.tsx +9 -25
  29. package/src/components/resources/resource-utils-contour.ts +34 -0
  30. package/src/components/resources/resource-utils.ts +91 -0
  31. package/src/components/shared/ResourceActionsBar.tsx +276 -144
  32. package/src/components/shared/ResourceRendererDispatch.tsx +59 -5
  33. package/src/components/shared/index.ts +1 -1
  34. package/src/components/timeline/shared.tsx +8 -10
  35. package/src/components/topology/K8sResourceNode.tsx +1 -0
  36. package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
  37. package/src/components/topology/TopologyGraph.tsx +2 -17
  38. package/src/components/topology/layout.ts +1 -0
  39. package/src/components/topology/topology.css +1 -0
  40. package/src/components/ui/CodeViewer.tsx +1 -2
  41. package/src/components/ui/ConfirmDialog.tsx +85 -130
  42. package/src/components/ui/DialogPortal.tsx +82 -0
  43. package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
  44. package/src/components/ui/Toast.tsx +5 -5
  45. package/src/components/ui/drawer-components.tsx +28 -22
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/workload/WorkloadView.tsx +30 -2
  48. package/src/theme/index.ts +3 -0
  49. package/src/theme/tailwind-theme.css +61 -0
  50. package/src/theme/variables.css +121 -0
  51. package/src/types/core.ts +13 -2
  52. package/src/utils/animation.ts +1 -1
  53. package/src/utils/badge-colors.ts +158 -123
  54. package/src/utils/log-format.ts +127 -35
  55. package/src/utils/navigation.test.ts +142 -0
  56. package/src/utils/navigation.ts +52 -47
  57. package/src/utils/resource-hierarchy.ts +5 -0
  58. 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.1.0",
3
+ "version": "1.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -19,10 +19,15 @@
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",
30
+ "test": "vitest run",
26
31
  "format": "prettier --write \"src/**/*.{ts,tsx}\""
27
32
  },
28
33
  "dependencies": {
@@ -59,6 +64,7 @@
59
64
  "react": "^19.2.4",
60
65
  "react-dom": "^19.2.4",
61
66
  "typescript": "^5.9.3",
67
+ "vitest": "^4.0.18",
62
68
  "yaml": "^2.7.1"
63
69
  }
64
70
  }
@@ -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 ease-out, left ${DURATION_DOCK}ms ease-out` }}
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
- {isExpanded && (
153
- <div className="flex-1 overflow-hidden w-full relative">
154
- {tabs.map(tab => (
155
- <div
156
- key={tab.id}
157
- className={tab.id === activeTabId ? 'absolute inset-0' : 'absolute inset-0 invisible'}
158
- >
159
- {renderTabContent(tab, tab.id === activeTabId)}
160
- </div>
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' ? Terminal : tab.type === 'workload-logs' ? Layers : FileText
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,3 +1,5 @@
1
1
  export * from './DockContext'
2
2
  export * from './BottomDock'
3
3
  export * from './TerminalTab'
4
+ export * from './NodeTerminalTab'
5
+ export * from './LocalTerminalTab'
@@ -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
- return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
71
- }
72
- if (status.sync === 'Synced' && status.health === 'Healthy') {
73
- return 'bg-green-500/20 text-green-400 border border-green-500/30'
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="px-2 py-0.5 rounded text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 inline-flex items-center gap-1">
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