From b990bd17925d314055ca79ed47eb83cc90acfcb9 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Mon, 1 Dec 2025 01:04:31 +0100 Subject: [PATCH] Update AppImage --- AppImage/app/api/gpu/nvidia/install/route.ts | 68 --- AppImage/components/hardware.tsx | 101 ++--- AppImage/components/hybrid-script-monitor.tsx | 428 ------------------ AppImage/components/script-terminal-modal.tsx | 162 +++++++ AppImage/scripts/flask_script_runner.py | 58 ++- AppImage/scripts/flask_terminal_routes.py | 163 +++++++ 6 files changed, 396 insertions(+), 584 deletions(-) delete mode 100644 AppImage/app/api/gpu/nvidia/install/route.ts delete mode 100644 AppImage/components/hybrid-script-monitor.tsx create mode 100644 AppImage/components/script-terminal-modal.tsx diff --git a/AppImage/app/api/gpu/nvidia/install/route.ts b/AppImage/app/api/gpu/nvidia/install/route.ts deleted file mode 100644 index 1a66946..0000000 --- a/AppImage/app/api/gpu/nvidia/install/route.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { NextResponse } from "next/server" - -export async function POST(request: Request) { - try { - const requestUrl = new URL(request.url) - const API_PORT = "8008" - - // Check if request comes through proxy (standard HTTP/HTTPS ports) - const isProxied = requestUrl.port === "" || requestUrl.port === "80" || requestUrl.port === "443" - - let flaskUrl: string - - if (isProxied) { - // Behind proxy - use same host but different path - flaskUrl = `${requestUrl.protocol}//${requestUrl.host}/api/scripts/execute` - } else { - // Direct access - use Flask port - flaskUrl = `${requestUrl.protocol}//${requestUrl.hostname}:${API_PORT}/api/scripts/execute` - } - - console.log("[v0] Starting NVIDIA driver installation") - console.log("[v0] Request URL:", requestUrl.href) - console.log("[v0] Flask URL:", flaskUrl) - - const response = await fetch(flaskUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - script_relative_path: "gpu_tpu/nvidia_installer.sh", - params: { - EXECUTION_MODE: "web", - WEB_LOG: "/tmp/nvidia_web_install.log", - }, - }), - }) - - if (!response.ok) { - const errorText = await response.text() - console.error("[v0] Flask API error:", response.status, errorText) - throw new Error(`Flask API error: ${response.statusText}`) - } - - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || "Failed to start installation") - } - - console.log("[v0] NVIDIA installation started, session_id:", data.session_id) - - return NextResponse.json({ - success: true, - session_id: data.session_id, - message: "NVIDIA installation started", - }) - } catch (error: any) { - console.error("[v0] NVIDIA installation error:", error) - return NextResponse.json( - { - success: false, - error: error.message || "Failed to start NVIDIA driver installation. Please try manually.", - }, - { status: 500 }, - ) - } -} diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index 44d8495..5c99d68 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -30,7 +30,7 @@ import { fetcher as swrFetcher, } from "../types/hardware" import { fetchApi } from "@/lib/api-config" -import { HybridScriptMonitor } from "./hybrid-script-monitor" +import { ScriptTerminalModal } from "./script-terminal-modal" const parseLsblkSize = (sizeStr: string | undefined): number => { if (!sizeStr) return 0 @@ -239,7 +239,7 @@ export default function Hardware() { const [selectedDisk, setSelectedDisk] = useState(null) const [selectedNetwork, setSelectedNetwork] = useState(null) const [selectedUPS, setSelectedUPS] = useState(null) - const [nvidiaSessionId, setNvidiaSessionId] = useState(null) + const [showNvidiaInstaller, setShowNvidiaInstaller] = useState(false) const [installingNvidiaDriver, setInstallingNvidiaDriver] = useState(false) const fetcher = async (url: string) => { @@ -257,39 +257,9 @@ export default function Hardware() { revalidateOnFocus: false, }) - const handleInstallNvidiaDriver = async () => { - console.log("[v0] NVIDIA installation button clicked") - - try { - const payload = { - script_name: "nvidia_installer", - script_relative_path: "gpu_tpu/nvidia_installer.sh", - params: { - EXECUTION_MODE: "web", - WEB_LOG: "/tmp/nvidia_web_install.log", - }, - } - - console.log("[v0] Calling Flask to execute script:", payload) - - const data = await fetchApi("/api/scripts/execute", { - method: "POST", - body: JSON.stringify(payload), - }) - - console.log("[v0] Flask response:", data) - - if (data.success && data.session_id) { - console.log("[v0] Installation started with session ID:", data.session_id) - setNvidiaSessionId(data.session_id) - } else { - console.error("[v0] Installation failed:", data.error) - alert(`Failed to install NVIDIA drivers: ${data.error}`) - } - } catch (error) { - console.error("[v0] Exception during installation:", error) - alert("Failed to start NVIDIA driver installation. Check console for details.") - } + const handleInstallNvidiaDriver = () => { + console.log("[v0] Opening NVIDIA installer terminal") + setShowNvidiaInstaller(true) } useEffect(() => { @@ -1130,18 +1100,11 @@ export default function Hardware() { {getMonitoringToolRecommendation(selectedGPU.vendor)}

{selectedGPU.vendor.toLowerCase().includes("nvidia") && ( - )} @@ -2057,23 +2020,35 @@ export default function Hardware() { {/* NVIDIA Installation Monitor */} - {nvidiaSessionId && ( - { - setNvidiaSessionId(null) + {/* { + setNvidiaSessionId(null) + mutateHardware() + }} + onComplete={(success) => { + console.log("[v0] NVIDIA installation completed:", success ? "success" : "failed") + if (success) { mutateHardware() - }} - onComplete={(success) => { - console.log("[v0] NVIDIA installation completed:", success ? "success" : "failed") - if (success) { - mutateHardware() - } - }} - /> - )} + } + }} + /> */} + { + setShowNvidiaInstaller(false) + mutateHardware() + }} + scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/nvidia_installer.sh" + scriptName="nvidia_installer" + params={{ + EXECUTION_MODE: "web", + }} + title="NVIDIA Driver Installation" + description="Installing NVIDIA proprietary drivers for GPU monitoring..." + /> ) } diff --git a/AppImage/components/hybrid-script-monitor.tsx b/AppImage/components/hybrid-script-monitor.tsx deleted file mode 100644 index 69b988b..0000000 --- a/AppImage/components/hybrid-script-monitor.tsx +++ /dev/null @@ -1,428 +0,0 @@ -"use client" - -import { useEffect, useState, useRef } from "react" -import { fetchApi, getApiUrl } from "@/lib/api-config" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Loader2, CheckCircle2, XCircle, TerminalIcon } from "lucide-react" - -import type { Terminal } from "xterm" -import type { FitAddon } from "xterm-addon-fit" - -interface HybridScriptMonitorProps { - sessionId: string | null - title?: string - description?: string - onClose: () => void - onComplete?: (success: boolean) => void -} - -interface ScriptInteraction { - type: "msgbox" | "yesno" | "inputbox" | "menu" - id: string - title: string - text: string - data?: string -} - -export function HybridScriptMonitor({ - sessionId, - title = "Script Execution", - description = "Monitoring script execution...", - onClose, - onComplete, -}: HybridScriptMonitorProps) { - const [interaction, setInteraction] = useState(null) - const [status, setStatus] = useState<"running" | "completed" | "failed">("running") - const [inputValue, setInputValue] = useState("") - const [selectedMenuItem, setSelectedMenuItem] = useState("") - const [isResponding, setIsResponding] = useState(false) - - const terminalRef = useRef(null) - const xtermRef = useRef(null) - const fitAddonRef = useRef(null) - const pollingIntervalRef = useRef(null) - - const decodeBase64 = (str: string): string => { - try { - return atob(str) - } catch (e) { - console.error("[v0] Failed to decode base64:", str, e) - return str - } - } - - useEffect(() => { - if (!terminalRef.current || xtermRef.current || typeof window === "undefined") return - - const loadTerminal = async () => { - const { Terminal } = await import("xterm") - const { FitAddon } = await import("xterm-addon-fit") - await import("xterm/css/xterm.css") - - const term = new Terminal({ - cursorBlink: false, - fontSize: 13, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - theme: { - background: "#1e1e1e", - foreground: "#d4d4d4", - cursor: "#d4d4d4", - black: "#000000", - red: "#cd3131", - green: "#0dbc79", - yellow: "#e5e510", - blue: "#2472c8", - magenta: "#bc3fbc", - cyan: "#11a8cd", - white: "#e5e5e5", - brightBlack: "#666666", - brightRed: "#f14c4c", - brightGreen: "#23d18b", - brightYellow: "#f5f543", - brightBlue: "#3b8eea", - brightMagenta: "#d670d6", - brightCyan: "#29b8db", - brightWhite: "#ffffff", - }, - convertEol: true, - disableStdin: true, - }) - - const fitAddon = new FitAddon() - term.loadAddon(fitAddon) - term.open(terminalRef.current!) - fitAddon.fit() - - xtermRef.current = term - fitAddonRef.current = fitAddon - - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit() - }) - resizeObserver.observe(terminalRef.current!) - - return () => { - resizeObserver.disconnect() - term.dispose() - xtermRef.current = null - fitAddonRef.current = null - } - } - - loadTerminal() - }, []) - - useEffect(() => { - if (!sessionId || !xtermRef.current) return - - const term = xtermRef.current - term.writeln("\x1b[32m[INFO] Conectando al stream de logs...\x1b[0m") - - const eventSourceUrl = getApiUrl(`/api/scripts/logs/${sessionId}`) - const eventSource = new EventSource(eventSourceUrl) - - eventSource.onopen = () => { - term.writeln("\x1b[32m[INFO] Conexión establecida con el servidor\x1b[0m") - } - - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data) - - if (data.type === "init") { - term.writeln(`\x1b[36m[INICIO] Ejecutando: ${data.script}\x1b[0m`) - term.writeln(`\x1b[36m[INICIO] Session ID: ${data.session_id}\x1b[0m`) - term.writeln("") - } else if (data.type === "raw") { - const message = data.message - - // Detectar WEB_INTERACTION y mostrar modal, pero NO escribir en terminal - if (message.includes("WEB_INTERACTION:")) { - const interactionPart = message.split("WEB_INTERACTION:")[1] - - if (interactionPart) { - const parts = interactionPart.split(":") - - if (parts.length >= 4) { - const [type, id, titleB64, textB64, ...dataParts] = parts - const dataB64 = dataParts.join(":") - - setInteraction({ - type: type as ScriptInteraction["type"], - id, - title: decodeBase64(titleB64), - text: decodeBase64(textB64), - data: dataB64 ? decodeBase64(dataB64) : undefined, - }) - } - } - } else { - term.writeln(message) - } - } else if (data.type === "error") { - term.writeln(`\x1b[31m[ERROR] ${data.message}\x1b[0m`) - } - } catch (e) { - term.writeln(`\x1b[31m[PARSE ERROR] ${event.data.substring(0, 100)}\x1b[0m`) - } - } - - eventSource.onerror = () => { - term.writeln("\x1b[31m[ERROR] Conexión perdida, reintentando...\x1b[0m") - } - - const pollStatus = async () => { - try { - const statusData = await fetchApi(`/api/scripts/status/${sessionId}`) - - if (statusData.status === "completed" || statusData.exit_code === 0) { - term.writeln("") - term.writeln("\x1b[32m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m") - term.writeln("\x1b[32m✓ Script completado exitosamente\x1b[0m") - term.writeln("\x1b[32m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m") - setStatus("completed") - eventSource.close() - if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current) - } - onComplete?.(true) - } else if (statusData.status === "failed" || (statusData.exit_code !== null && statusData.exit_code !== 0)) { - term.writeln("") - term.writeln("\x1b[31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m") - term.writeln(`\x1b[31m✗ Script falló con código de salida: ${statusData.exit_code}\x1b[0m`) - term.writeln("\x1b[31m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m") - setStatus("failed") - eventSource.close() - if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current) - } - onComplete?.(false) - } - - // Detectar interacciones pendientes desde el status - if (statusData.pending_interaction) { - const parts = statusData.pending_interaction.split(":") - if (parts.length >= 4) { - const [type, id, titleB64, textB64, ...dataParts] = parts - const dataB64 = dataParts.join(":") - - setInteraction({ - type: type as ScriptInteraction["type"], - id, - title: decodeBase64(titleB64), - text: decodeBase64(textB64), - data: dataB64 ? decodeBase64(dataB64) : undefined, - }) - } - } - } catch (error) { - console.error("[v0] Error polling status:", error) - } - } - - pollStatus() - pollingIntervalRef.current = setInterval(pollStatus, 2000) - - return () => { - eventSource.close() - if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current) - } - } - }, [sessionId, onComplete]) - - const handleInteractionResponse = async (response: string) => { - if (!interaction || !sessionId || !xtermRef.current) return - - const term = xtermRef.current - setIsResponding(true) - - try { - term.writeln(`\x1b[33m[USUARIO] Respuesta: ${response}\x1b[0m`) - - await fetchApi("/api/scripts/respond", { - method: "POST", - body: JSON.stringify({ - session_id: sessionId, - interaction_id: interaction.id, - value: response, - }), - }) - - setInteraction(null) - setInputValue("") - setSelectedMenuItem("") - } catch (error) { - term.writeln(`\x1b[31m[ERROR] Error enviando respuesta: ${error}\x1b[0m`) - } finally { - setIsResponding(false) - } - } - - const renderInteractionModal = () => { - if (!interaction) return null - - switch (interaction.type) { - case "msgbox": - return ( - {}}> - - - {interaction.title} - - {interaction.text} -
- -
-
-
- ) - - case "yesno": - return ( - {}}> - - - {interaction.title} - - {interaction.text} -
- - -
-
-
- ) - - case "inputbox": - return ( - {}}> - - - {interaction.title} - - {interaction.text} -
-
- - setInputValue(e.target.value)} - placeholder={interaction.data || "Introduce el valor..."} - autoFocus - /> -
-
- - -
-
-
-
- ) - - case "menu": - const menuItems = interaction.data?.split("|").filter(Boolean) || [] - return ( - {}}> - - - {interaction.title} - - {interaction.text} - -
- {menuItems.map((item, index) => { - const [value, label] = item.includes(":") ? item.split(":") : [item, item] - return ( - - ) - })} -
-
-
- - -
-
-
- ) - - default: - return null - } - } - - if (!sessionId) return null - - return ( - <> - - - - - {status === "running" && } - {status === "completed" && } - {status === "failed" && } - - {title} - - {description} - - -
-
-
-
- -
-
- Session ID: {sessionId} -
- {status !== "running" && ( - - )} -
-
- -
- - {renderInteractionModal()} - - ) -} diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx new file mode 100644 index 0000000..cc8e14b --- /dev/null +++ b/AppImage/components/script-terminal-modal.tsx @@ -0,0 +1,162 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { AlertCircle, CheckCircle2 } from "lucide-react" +import { io, type Socket } from "socket.io-client" + +interface ScriptTerminalModalProps { + open: boolean + onClose: () => void + scriptPath: string + scriptName: string + params?: Record + title: string + description: string +} + +interface WebInteraction { + type: "yesno" | "menu" | "msgbox" | "input" + id: string + title: string + text: string + options?: string[] +} + +export function ScriptTerminalModal({ + open, + onClose, + scriptPath, + scriptName, + params = {}, + title, + description, +}: ScriptTerminalModalProps) { + const [socket, setSocket] = useState(null) + const [sessionId] = useState(() => Math.random().toString(36).substring(7)) + const [terminalOutput, setTerminalOutput] = useState("") + const [isRunning, setIsRunning] = useState(false) + const [isComplete, setIsComplete] = useState(false) + const [exitCode, setExitCode] = useState(null) + const [interaction, setInteraction] = useState(null) + + useEffect(() => { + if (!open) return + + const newSocket = io("http://localhost:8008", { + transports: ["websocket"], + reconnection: true, + }) + + newSocket.on("connect", () => { + console.log("[v0] WebSocket connected") + // Ejecutar el script + newSocket.emit("execute_script", { + session_id: sessionId, + script_path: scriptPath, + script_name: scriptName, + params, + }) + setIsRunning(true) + }) + + newSocket.on("script_output", (data: { line: string }) => { + setTerminalOutput((prev) => prev + data.line + "\n") + }) + + newSocket.on("web_interaction", (data: WebInteraction) => { + console.log("[v0] Web interaction received:", data) + setInteraction(data) + }) + + newSocket.on("script_complete", (data: { exit_code: number }) => { + console.log("[v0] Script complete, exit code:", data.exit_code) + setIsRunning(false) + setIsComplete(true) + setExitCode(data.exit_code) + }) + + setSocket(newSocket) + + return () => { + newSocket.disconnect() + } + }, [open, sessionId, scriptPath, scriptName, params]) + + const handleInteractionResponse = (value: string) => { + if (!socket || !interaction) return + + console.log("[v0] Sending interaction response:", value) + socket.emit("interaction_response", { + session_id: sessionId, + interaction_id: interaction.id, + value, + }) + + // Escribir la respuesta en la terminal + setTerminalOutput((prev) => prev + `\n> ${value}\n`) + setInteraction(null) + } + + return ( + <> + + + + + {isRunning && ⚙️} + {isComplete && exitCode === 0 && } + {isComplete && exitCode !== 0 && } + {title} + +

{description}

+
+ +
+
{terminalOutput}
+ {isRunning && _} +
+ + {isComplete && ( +
+ {exitCode === 0 ? "✓ Script completed successfully" : `✗ Script failed with exit code ${exitCode}`} +
+ )} + +
+ Session ID: {sessionId} + +
+
+
+ + {interaction && ( + setInteraction(null)}> + + + {interaction.title} + +

{interaction.text}

+ +
+ {interaction.type === "yesno" && ( + <> + + + + )} + {interaction.type === "msgbox" && } +
+
+
+ )} + + ) +} diff --git a/AppImage/scripts/flask_script_runner.py b/AppImage/scripts/flask_script_runner.py index c7b8bdd..a68ac30 100644 --- a/AppImage/scripts/flask_script_runner.py +++ b/AppImage/scripts/flask_script_runner.py @@ -91,36 +91,44 @@ class ScriptRunner: lines_read = [0] # Lista para compartir entre threads - # Monitor output for interactions def monitor_output(): print(f"[DEBUG] monitor_output thread started for session {session_id}", file=sys.stderr, flush=True) + print(f"[DEBUG] Will monitor log file: {log_file}", file=sys.stderr, flush=True) + try: - with open(log_file, 'a') as log_f: - while True: - line = process.stdout.readline() - if not line: - print(f"[DEBUG] No more lines from stdout (EOF reached)", file=sys.stderr, flush=True) - break - - decoded_line = line.decode('utf-8', errors='replace').rstrip() - lines_read[0] += 1 - - print(f"[DEBUG] Read line {lines_read[0]}: {decoded_line[:100]}...", file=sys.stderr, flush=True) - - log_f.write(decoded_line + '\n') - log_f.flush() - - # Check for interaction requests - try: - if decoded_line.strip().startswith('{'): - data = json.loads(decoded_line.strip()) - if data.get('type') == 'interaction_request': - session['pending_interaction'] = data - print(f"[DEBUG] Detected interaction request: {data}", file=sys.stderr, flush=True) - except json.JSONDecodeError: - pass + # Read log file in real-time (similar to tail -f) + last_position = 0 + + # Wait a moment for script to start writing + time.sleep(0.5) + + while process.poll() is None or last_position < os.path.getsize(log_file): + try: + if os.path.exists(log_file): + with open(log_file, 'r') as log_f: + log_f.seek(last_position) + new_lines = log_f.readlines() + + for line in new_lines: + decoded_line = line.rstrip() + if decoded_line: # Skip empty lines + lines_read[0] += 1 + print(f"[DEBUG] Read line {lines_read[0]} from log: {decoded_line[:100]}...", file=sys.stderr, flush=True) + + # Check for interaction requests in the line + if 'WEB_INTERACTION:' in decoded_line: + print(f"[DEBUG] Detected WEB_INTERACTION line: {decoded_line}", file=sys.stderr, flush=True) + session['pending_interaction'] = decoded_line + + last_position = log_f.tell() + + except Exception as e: + print(f"[DEBUG ERROR] Error reading log file: {e}", file=sys.stderr, flush=True) + + time.sleep(0.1) # Poll every 100ms print(f"[DEBUG] monitor_output thread finished. Total lines read: {lines_read[0]}", file=sys.stderr, flush=True) + except Exception as e: print(f"[DEBUG ERROR] Exception in monitor_output: {e}", file=sys.stderr, flush=True) diff --git a/AppImage/scripts/flask_terminal_routes.py b/AppImage/scripts/flask_terminal_routes.py index 65410ae..fe2c892 100644 --- a/AppImage/scripts/flask_terminal_routes.py +++ b/AppImage/scripts/flask_terminal_routes.py @@ -236,6 +236,169 @@ def terminal_websocket(ws): if session_id in active_sessions: del active_sessions[session_id] +@sock.route('/ws/script/') +def script_websocket(ws, session_id): + """WebSocket endpoint for executing scripts with hybrid web mode""" + + # Receive initial script execution request + try: + init_data = ws.receive(timeout=5) + if not init_data: + ws.send(json.dumps({'type': 'error', 'message': 'No script data received'})) + return + + script_data = json.loads(init_data) + script_path = script_data.get('script_path') + params = script_data.get('params', {}) + + if not script_path: + ws.send(json.dumps({'type': 'error', 'message': 'No script_path provided'})) + return + + except Exception as e: + ws.send(json.dumps({'type': 'error', 'message': f'Invalid init data: {str(e)}'})) + return + + # Create pseudo-terminal for script execution + master_fd, slave_fd = pty.openpty() + + # Build environment variables from params + env = os.environ.copy() + for key, value in params.items(): + env[key] = str(value) + + # Start script process with PTY + script_process = subprocess.Popen( + ['/bin/bash', script_path], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + preexec_fn=os.setsid, + env=env + ) + + # Set non-blocking mode for master_fd + flags = fcntl.fcntl(master_fd, fcntl.F_GETFL) + fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + # Set terminal size + set_winsize(master_fd, 30, 120) + + # Thread to read script output and forward to WebSocket + def read_script_output(): + buffer = "" + while True: + try: + r, _, _ = select.select([master_fd], [], [], 0.01) + if master_fd in r: + try: + data = os.read(master_fd, 4096) + if not data: + break + + text = data.decode('utf-8', errors='ignore') + buffer += text + + # Process line by line to detect WEB_INTERACTION + lines = buffer.split('\n') + buffer = lines[-1] # Keep incomplete line in buffer + + for line in lines[:-1]: + # Detect WEB_INTERACTION lines + if line.strip().startswith('WEB_INTERACTION:'): + # Parse interaction: WEB_INTERACTION:type:id:title_b64:text_b64:data_b64 + try: + parts = line.strip().split(':', 6) + if len(parts) >= 5: + interaction = { + 'type': 'interaction', + 'interaction_type': parts[1], + 'interaction_id': parts[2], + 'title': parts[3], + 'text': parts[4], + 'data': parts[5] if len(parts) > 5 else '' + } + ws.send(json.dumps(interaction)) + continue + except Exception as e: + print(f"Error parsing WEB_INTERACTION: {e}") + + # Regular output line + ws.send(json.dumps({ + 'type': 'output', + 'data': line + '\n' + })) + except OSError: + break + except Exception as e: + print(f"Error reading script output: {e}") + break + + # Send completion message + exit_code = script_process.poll() + ws.send(json.dumps({ + 'type': 'exit', + 'code': exit_code if exit_code is not None else 0 + })) + + output_thread = threading.Thread(target=read_script_output, daemon=True) + output_thread.start() + + try: + while True: + # Receive user input or interaction responses + data = ws.receive(timeout=None) + + if data is None: + break + + try: + msg = json.loads(data) + + # Handle resize + if msg.get('type') == 'resize': + cols = int(msg.get('cols', 120)) + rows = int(msg.get('rows', 30)) + set_winsize(master_fd, rows, cols) + continue + + # Handle interaction response + if msg.get('type') == 'response': + response_value = msg.get('value', '') + # Write response to script's stdin + os.write(master_fd, (str(response_value) + '\n').encode('utf-8')) + continue + + except json.JSONDecodeError: + # Raw text input, send to script + os.write(master_fd, data.encode('utf-8')) + + # Check if process is still alive + if script_process.poll() is not None: + break + + except Exception as e: + print(f"Script session error: {e}") + finally: + # Cleanup + try: + script_process.terminate() + script_process.wait(timeout=1) + except: + try: + script_process.kill() + except: + pass + + try: + os.close(master_fd) + except: + pass + + try: + os.close(slave_fd) + except: + pass def init_terminal_routes(app): """Initialize terminal routes with Flask app"""