diff --git a/src/components/Kubernetes/InteractiveAttachModal.tsx b/src/components/Kubernetes/InteractiveAttachModal.tsx new file mode 100644 index 00000000..ae8b5742 --- /dev/null +++ b/src/components/Kubernetes/InteractiveAttachModal.tsx @@ -0,0 +1,217 @@ +import React, { useEffect, useRef, useState } from "react"; +import { X } from "lucide-react"; +import { Terminal as XTerminal, type ITerminalOptions } from "xterm"; +import { FitAddon } from "xterm-addon-fit"; +import { WebLinksAddon } from "xterm-addon-web-links"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { + startPtyAttachSessionCmd, + sendPtyStdinCmd, + resizePtySessionCmd, + terminatePtySessionCmd, +} from "@/lib/tauriCommands"; + +interface InteractiveAttachModalProps { + clusterId: string; + namespace: string; + pod: string; + container?: string; + onClose: () => void; +} + +const XTERM_OPTIONS: ITerminalOptions = { + cursorBlink: true, + theme: { + background: "#0f172a", + foreground: "#4ade80", + cursor: "#4ade80", + }, + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + fontSize: 13, + convertEol: true, + rows: 24, + cols: 80, +}; + +export function InteractiveAttachModal({ + clusterId, + namespace, + pod, + container, + onClose, +}: InteractiveAttachModalProps) { + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const [sessionId, setSessionId] = useState(null); + const [error, setError] = useState(null); + const unlistenOutputRef = useRef(null); + const unlistenClosedRef = useRef(null); + const unlistenErrorRef = useRef(null); + + // Initialize terminal and start session + useEffect(() => { + if (!terminalRef.current) return; + + const term = new XTerminal(XTERM_OPTIONS); + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + + term.loadAddon(fitAddon); + term.loadAddon(webLinksAddon); + term.open(terminalRef.current); + + try { + fitAddon.fit(); + } catch { + // Ignore first-frame race + } + + xtermRef.current = term; + fitAddonRef.current = fitAddon; + + // Start PTY session + (async () => { + try { + term.write("\r\n\x1b[1;32mAttaching to pod...\x1b[0m\r\n"); + + const sid = await startPtyAttachSessionCmd( + clusterId, + namespace, + pod, + container + ); + setSessionId(sid); + + // Listen for output from backend + const unlistenOutput = await listen( + `terminal-output-${sid}`, + (event) => { + const data = new Uint8Array(event.payload); + term.write(data); + } + ); + unlistenOutputRef.current = unlistenOutput; + + // Listen for session closed + const unlistenClosed = await listen(`terminal-closed-${sid}`, () => { + term.write("\r\n\x1b[1;31m[Session closed]\x1b[0m\r\n"); + }); + unlistenClosedRef.current = unlistenClosed; + + // Listen for errors + const unlistenError = await listen( + `terminal-error-${sid}`, + (event) => { + term.write(`\r\n\x1b[1;31m[Error: ${event.payload}]\x1b[0m\r\n`); + } + ); + unlistenErrorRef.current = unlistenError; + + // Handle user input + term.onData((data) => { + if (sid) { + const bytes = Array.from(new TextEncoder().encode(data)); + sendPtyStdinCmd(sid, bytes).catch((err) => { + term.write(`\r\n\x1b[31mError sending input: ${err}\x1b[0m\r\n`); + }); + } + }); + + // Handle terminal resize + term.onResize((size) => { + if (sid) { + resizePtySessionCmd(sid, size.rows, size.cols).catch((err) => { + console.error("Failed to resize PTY:", err); + }); + } + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + term.write(`\r\n\x1b[1;31mFailed to start session: ${msg}\x1b[0m\r\n`); + } + })(); + + // Cleanup on unmount + return () => { + if (unlistenOutputRef.current) { + unlistenOutputRef.current(); + } + if (unlistenClosedRef.current) { + unlistenClosedRef.current(); + } + if (unlistenErrorRef.current) { + unlistenErrorRef.current(); + } + if (sessionId) { + terminatePtySessionCmd(sessionId).catch(console.error); + } + term.dispose(); + fitAddon.dispose(); + }; + }, [clusterId, namespace, pod, container]); + + // Handle window resize + useEffect(() => { + const handleResize = () => { + if (fitAddonRef.current) { + try { + fitAddonRef.current.fit(); + } catch { + // Ignore + } + } + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const handleClose = () => { + if (sessionId) { + terminatePtySessionCmd(sessionId).catch(console.error); + } + onClose(); + }; + + return ( +
+
+ {/* Header */} +
+
+ + kubectl attach -it {pod} + {container && ` -c ${container}`} + +
+ +
+ + {/* Error display */} + {error && ( +
+ {error} +
+ )} + + {/* Terminal */} +
+ + {/* Footer */} +
+

+ Attached to running process - Press Ctrl+C to detach +

+
+
+
+ ); +} diff --git a/src/components/Kubernetes/InteractiveShellModal.tsx b/src/components/Kubernetes/InteractiveShellModal.tsx new file mode 100644 index 00000000..f1ca53b0 --- /dev/null +++ b/src/components/Kubernetes/InteractiveShellModal.tsx @@ -0,0 +1,217 @@ +import React, { useEffect, useRef, useState } from "react"; +import { X } from "lucide-react"; +import { Terminal as XTerminal, type ITerminalOptions } from "xterm"; +import { FitAddon } from "xterm-addon-fit"; +import { WebLinksAddon } from "xterm-addon-web-links"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { + startPtyExecSessionCmd, + sendPtyStdinCmd, + resizePtySessionCmd, + terminatePtySessionCmd, +} from "@/lib/tauriCommands"; + +interface InteractiveShellModalProps { + clusterId: string; + namespace: string; + pod: string; + container?: string; + onClose: () => void; +} + +const XTERM_OPTIONS: ITerminalOptions = { + cursorBlink: true, + theme: { + background: "#0f172a", + foreground: "#4ade80", + cursor: "#4ade80", + }, + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + fontSize: 13, + convertEol: true, + rows: 24, + cols: 80, +}; + +export function InteractiveShellModal({ + clusterId, + namespace, + pod, + container, + onClose, +}: InteractiveShellModalProps) { + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const [sessionId, setSessionId] = useState(null); + const [error, setError] = useState(null); + const unlistenOutputRef = useRef(null); + const unlistenClosedRef = useRef(null); + const unlistenErrorRef = useRef(null); + + // Initialize terminal and start session + useEffect(() => { + if (!terminalRef.current) return; + + const term = new XTerminal(XTERM_OPTIONS); + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + + term.loadAddon(fitAddon); + term.loadAddon(webLinksAddon); + term.open(terminalRef.current); + + try { + fitAddon.fit(); + } catch { + // Ignore first-frame race + } + + xtermRef.current = term; + fitAddonRef.current = fitAddon; + + // Start PTY session + (async () => { + try { + term.write("\r\n\x1b[1;32mConnecting to pod...\x1b[0m\r\n"); + + const sid = await startPtyExecSessionCmd( + clusterId, + namespace, + pod, + container + ); + setSessionId(sid); + + // Listen for output from backend + const unlistenOutput = await listen( + `terminal-output-${sid}`, + (event) => { + const data = new Uint8Array(event.payload); + term.write(data); + } + ); + unlistenOutputRef.current = unlistenOutput; + + // Listen for session closed + const unlistenClosed = await listen(`terminal-closed-${sid}`, () => { + term.write("\r\n\x1b[1;31m[Session closed]\x1b[0m\r\n"); + }); + unlistenClosedRef.current = unlistenClosed; + + // Listen for errors + const unlistenError = await listen( + `terminal-error-${sid}`, + (event) => { + term.write(`\r\n\x1b[1;31m[Error: ${event.payload}]\x1b[0m\r\n`); + } + ); + unlistenErrorRef.current = unlistenError; + + // Handle user input + term.onData((data) => { + if (sid) { + const bytes = Array.from(new TextEncoder().encode(data)); + sendPtyStdinCmd(sid, bytes).catch((err) => { + term.write(`\r\n\x1b[31mError sending input: ${err}\x1b[0m\r\n`); + }); + } + }); + + // Handle terminal resize + term.onResize((size) => { + if (sid) { + resizePtySessionCmd(sid, size.rows, size.cols).catch((err) => { + console.error("Failed to resize PTY:", err); + }); + } + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + term.write(`\r\n\x1b[1;31mFailed to start session: ${msg}\x1b[0m\r\n`); + } + })(); + + // Cleanup on unmount + return () => { + if (unlistenOutputRef.current) { + unlistenOutputRef.current(); + } + if (unlistenClosedRef.current) { + unlistenClosedRef.current(); + } + if (unlistenErrorRef.current) { + unlistenErrorRef.current(); + } + if (sessionId) { + terminatePtySessionCmd(sessionId).catch(console.error); + } + term.dispose(); + fitAddon.dispose(); + }; + }, [clusterId, namespace, pod, container]); + + // Handle window resize + useEffect(() => { + const handleResize = () => { + if (fitAddonRef.current) { + try { + fitAddonRef.current.fit(); + } catch { + // Ignore + } + } + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const handleClose = () => { + if (sessionId) { + terminatePtySessionCmd(sessionId).catch(console.error); + } + onClose(); + }; + + return ( +
+
+ {/* Header */} +
+
+ + kubectl exec -it {pod} + {container && ` -c ${container}`} -- sh + +
+ +
+ + {/* Error display */} + {error && ( +
+ {error} +
+ )} + + {/* Terminal */} +
+ + {/* Footer */} +
+

+ Interactive shell session - Press Ctrl+D or type "exit" to close +

+
+
+
+ ); +} diff --git a/src/components/Kubernetes/WorkloadLogsModal.tsx b/src/components/Kubernetes/WorkloadLogsModal.tsx index 1bc096e7..a8b1c63f 100644 --- a/src/components/Kubernetes/WorkloadLogsModal.tsx +++ b/src/components/Kubernetes/WorkloadLogsModal.tsx @@ -163,9 +163,9 @@ export function WorkloadLogsModal({ ) : ( - - - +
+ Select pod first +
)}