Update AppImage

This commit is contained in:
MacRimi
2025-12-01 01:04:31 +01:00
parent 88667416d8
commit b990bd1792
6 changed files with 396 additions and 584 deletions

View File

@@ -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 },
)
}
}

View File

@@ -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<StorageDevice | null>(null)
const [selectedNetwork, setSelectedNetwork] = useState<PCIDevice | null>(null)
const [selectedUPS, setSelectedUPS] = useState<any>(null)
const [nvidiaSessionId, setNvidiaSessionId] = useState<string | null>(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)}
</p>
{selectedGPU.vendor.toLowerCase().includes("nvidia") && (
<Button onClick={handleInstallNvidiaDriver} disabled={!!nvidiaSessionId} className="w-full">
{nvidiaSessionId ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Installing...
</>
) : (
<Button onClick={handleInstallNvidiaDriver} className="w-full">
<>
<Download className="mr-2 h-4 w-4" />
Install NVIDIA Drivers
</>
)}
</Button>
)}
</div>
@@ -2057,8 +2020,7 @@ export default function Hardware() {
</Dialog>
{/* NVIDIA Installation Monitor */}
{nvidiaSessionId && (
<HybridScriptMonitor
{/* <HybridScriptMonitor
sessionId={nvidiaSessionId}
title="NVIDIA Driver Installation"
description="Installing NVIDIA proprietary drivers for GPU monitoring..."
@@ -2072,8 +2034,21 @@ export default function Hardware() {
mutateHardware()
}
}}
/> */}
<ScriptTerminalModal
open={showNvidiaInstaller}
onClose={() => {
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..."
/>
)}
</div>
)
}

View File

@@ -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<ScriptInteraction | null>(null)
const [status, setStatus] = useState<"running" | "completed" | "failed">("running")
const [inputValue, setInputValue] = useState("")
const [selectedMenuItem, setSelectedMenuItem] = useState<string>("")
const [isResponding, setIsResponding] = useState(false)
const terminalRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(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 (
<Dialog open={true} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{interaction.title}</DialogTitle>
</DialogHeader>
<DialogDescription className="py-6 text-base whitespace-pre-wrap">{interaction.text}</DialogDescription>
<div className="flex justify-end">
<Button onClick={() => handleInteractionResponse("ok")} disabled={isResponding}>
{isResponding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
OK
</Button>
</div>
</DialogContent>
</Dialog>
)
case "yesno":
return (
<Dialog open={true} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{interaction.title}</DialogTitle>
</DialogHeader>
<DialogDescription className="py-6 text-base whitespace-pre-wrap">{interaction.text}</DialogDescription>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => handleInteractionResponse("no")} disabled={isResponding}>
No
</Button>
<Button onClick={() => handleInteractionResponse("yes")} disabled={isResponding}>
{isResponding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Yes
</Button>
</div>
</DialogContent>
</Dialog>
)
case "inputbox":
return (
<Dialog open={true} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{interaction.title}</DialogTitle>
</DialogHeader>
<DialogDescription className="py-4 text-base whitespace-pre-wrap">{interaction.text}</DialogDescription>
<div className="space-y-4">
<div>
<Label htmlFor="input-value">Valor</Label>
<Input
id="input-value"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={interaction.data || "Introduce el valor..."}
autoFocus
/>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => handleInteractionResponse("")} disabled={isResponding}>
Cancelar
</Button>
<Button onClick={() => handleInteractionResponse(inputValue)} disabled={isResponding || !inputValue}>
{isResponding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
OK
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
case "menu":
const menuItems = interaction.data?.split("|").filter(Boolean) || []
return (
<Dialog open={true} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>{interaction.title}</DialogTitle>
</DialogHeader>
<DialogDescription className="py-4 text-base whitespace-pre-wrap">{interaction.text}</DialogDescription>
<ScrollArea className="max-h-96 pr-4">
<div className="space-y-2">
{menuItems.map((item, index) => {
const [value, label] = item.includes(":") ? item.split(":") : [item, item]
return (
<Button
key={index}
variant={selectedMenuItem === value ? "default" : "outline"}
className="w-full justify-start text-left h-auto py-3 px-4"
onClick={() => setSelectedMenuItem(value)}
>
{label}
</Button>
)
})}
</div>
</ScrollArea>
<div className="flex justify-end gap-3 mt-4 pt-4 border-t">
<Button variant="outline" onClick={() => handleInteractionResponse("")} disabled={isResponding}>
Cancelar
</Button>
<Button
onClick={() => handleInteractionResponse(selectedMenuItem)}
disabled={isResponding || !selectedMenuItem}
>
{isResponding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Seleccionar
</Button>
</div>
</DialogContent>
</Dialog>
)
default:
return null
}
}
if (!sessionId) return null
return (
<>
<Dialog open={true} onOpenChange={status !== "running" ? onClose : undefined}>
<DialogContent className="max-w-5xl max-h-[85vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{status === "running" && <Loader2 className="h-5 w-5 animate-spin" />}
{status === "completed" && <CheckCircle2 className="h-5 w-5 text-green-500" />}
{status === "failed" && <XCircle className="h-5 w-5 text-red-500" />}
<TerminalIcon className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="border rounded-lg overflow-hidden bg-[#1e1e1e]">
<div ref={terminalRef} className="h-[500px] p-2" style={{ width: "100%", height: "500px" }} />
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-muted-foreground">
Session ID: <span className="font-mono">{sessionId}</span>
</div>
{status !== "running" && (
<Button onClick={onClose} size="lg">
Cerrar
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
{renderInteractionModal()}
</>
)
}

View File

@@ -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<string, string>
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<Socket | null>(null)
const [sessionId] = useState(() => Math.random().toString(36).substring(7))
const [terminalOutput, setTerminalOutput] = useState<string>("")
const [isRunning, setIsRunning] = useState(false)
const [isComplete, setIsComplete] = useState(false)
const [exitCode, setExitCode] = useState<number | null>(null)
const [interaction, setInteraction] = useState<WebInteraction | null>(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 (
<>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isRunning && <span className="animate-spin"></span>}
{isComplete && exitCode === 0 && <CheckCircle2 className="w-5 h-5 text-green-500" />}
{isComplete && exitCode !== 0 && <AlertCircle className="w-5 h-5 text-red-500" />}
{title}
</DialogTitle>
<p className="text-sm text-muted-foreground">{description}</p>
</DialogHeader>
<div className="flex-1 bg-black rounded-md p-4 overflow-auto font-mono text-sm text-green-400">
<pre className="whitespace-pre-wrap">{terminalOutput}</pre>
{isRunning && <span className="animate-pulse">_</span>}
</div>
{isComplete && (
<div
className={`p-3 rounded-md ${exitCode === 0 ? "bg-green-500/10 text-green-500" : "bg-red-500/10 text-red-500"}`}
>
{exitCode === 0 ? "✓ Script completed successfully" : `✗ Script failed with exit code ${exitCode}`}
</div>
)}
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Session ID: {sessionId}</span>
<Button onClick={onClose} disabled={isRunning}>
{isRunning ? "Running..." : "Close"}
</Button>
</div>
</DialogContent>
</Dialog>
{interaction && (
<Dialog open={true} onOpenChange={() => setInteraction(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{interaction.title}</DialogTitle>
</DialogHeader>
<p className="text-sm">{interaction.text}</p>
<div className="flex gap-2 justify-end">
{interaction.type === "yesno" && (
<>
<Button variant="outline" onClick={() => handleInteractionResponse("no")}>
No
</Button>
<Button onClick={() => handleInteractionResponse("yes")}>Yes</Button>
</>
)}
{interaction.type === "msgbox" && <Button onClick={() => handleInteractionResponse("ok")}>OK</Button>}
</div>
</DialogContent>
</Dialog>
)}
</>
)
}

View File

@@ -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)
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
print(f"[DEBUG] Will monitor log file: {log_file}", file=sys.stderr, flush=True)
decoded_line = line.decode('utf-8', errors='replace').rstrip()
try:
# 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)
print(f"[DEBUG] Read line {lines_read[0]}: {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
log_f.write(decoded_line + '\n')
log_f.flush()
last_position = log_f.tell()
# 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
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)

View File

@@ -236,6 +236,169 @@ def terminal_websocket(ws):
if session_id in active_sessions:
del active_sessions[session_id]
@sock.route('/ws/script/<session_id>')
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"""