* feat: Add Shell button for interactive LXC container access - Add Shell button to ScriptInstallationCard for SSH scripts with container_id - Implement shell state management in InstalledScriptsTab - Add shell execution methods in server.js (local and SSH) - Add isShell prop to Terminal component - Implement smooth scrolling to terminal when opened - Add highlight effect for better UX - Shell sessions are interactive (no auto-commands like update) The Shell button provides direct interactive access to LXC containers without automatically sending update commands, allowing users to manually execute commands in the container shell. * fix: Include SSH authentication fields in installed scripts data - Add SSH key fields (auth_type, ssh_key, ssh_key_passphrase, ssh_port) to database query - Update InstalledScript interface to include SSH authentication fields - Fix server data construction in handleOpenShell and handleUpdateScript - Now properly supports SSH key authentication for shell and update operations This fixes the issue where SSH key authentication was not being used even when configured in server settings, as the installed scripts data was missing the SSH authentication fields. * fix: Resolve TypeScript and ESLint build errors - Replace logical OR (||) with nullish coalescing (??) operators - Remove unnecessary type assertion for container_id - Add missing dependencies to useEffect and useCallback hooks - Remove unused variable in SSHKeyInput component - Add isShell property to WebSocketMessage type definition - Fix ServerInfo type to allow null in shell execution methods All TypeScript and ESLint errors resolved, build now passes successfully.
685 lines
24 KiB
TypeScript
685 lines
24 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import '@xterm/xterm/css/xterm.css';
|
|
import { Button } from './ui/button';
|
|
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
interface TerminalProps {
|
|
scriptPath: string;
|
|
onClose: () => void;
|
|
mode?: 'local' | 'ssh';
|
|
server?: any;
|
|
isUpdate?: boolean;
|
|
isShell?: boolean;
|
|
containerId?: string;
|
|
}
|
|
|
|
interface TerminalMessage {
|
|
type: 'start' | 'output' | 'error' | 'end';
|
|
data: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) {
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [isRunning, setIsRunning] = useState(false);
|
|
const [isClient, setIsClient] = useState(false);
|
|
const [mobileInput, setMobileInput] = useState('');
|
|
const [showMobileInput, setShowMobileInput] = useState(false);
|
|
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [isStopped, setIsStopped] = useState(false);
|
|
const [isTerminalReady, setIsTerminalReady] = useState(false);
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
const xtermRef = useRef<any>(null);
|
|
const fitAddonRef = useRef<any>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const inputHandlerRef = useRef<((data: string) => void) | null>(null);
|
|
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
|
const isConnectingRef = useRef<boolean>(false);
|
|
const hasConnectedRef = useRef<boolean>(false);
|
|
|
|
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
|
|
|
|
const handleMessage = useCallback((message: TerminalMessage) => {
|
|
if (!xtermRef.current) return;
|
|
|
|
const timestamp = new Date(message.timestamp).toLocaleTimeString();
|
|
const prefix = `[${timestamp}] `;
|
|
|
|
switch (message.type) {
|
|
case 'start':
|
|
xtermRef.current.writeln(`${prefix}[START] ${message.data}`);
|
|
setIsRunning(true);
|
|
break;
|
|
case 'output':
|
|
// Write directly to terminal - xterm.js handles ANSI codes natively
|
|
xtermRef.current.write(message.data);
|
|
break;
|
|
case 'error':
|
|
// Check if this looks like ANSI terminal output (contains escape codes)
|
|
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
|
|
// This is likely terminal output sent to stderr, treat it as normal output
|
|
xtermRef.current.write(message.data);
|
|
} else if (message.data.includes('TERM environment variable not set')) {
|
|
// This is a common warning, treat as normal output
|
|
xtermRef.current.write(message.data);
|
|
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
|
|
// This is a script error, show it with error prefix
|
|
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
|
|
} else {
|
|
// This is a real error, show it with error prefix
|
|
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
|
|
}
|
|
break;
|
|
case 'end':
|
|
setIsRunning(false);
|
|
|
|
// Check if this is an LXC creation script
|
|
const isLxcCreation = scriptPath.includes('ct/') ||
|
|
scriptPath.includes('create_lxc') ||
|
|
(containerId != null) ||
|
|
scriptName.includes('lxc') ||
|
|
scriptName.includes('container');
|
|
|
|
if (isLxcCreation && message.data.includes('SSH script execution finished with code: 0')) {
|
|
// Display prominent LXC creation completion message
|
|
xtermRef.current.writeln('');
|
|
xtermRef.current.writeln('#########################################');
|
|
xtermRef.current.writeln('########## LXC CREATION FINISHED ########');
|
|
xtermRef.current.writeln('#########################################');
|
|
xtermRef.current.writeln('');
|
|
} else {
|
|
xtermRef.current.writeln(`${prefix}✅ ${message.data}`);
|
|
}
|
|
break;
|
|
}
|
|
}, [scriptPath, containerId, scriptName]);
|
|
|
|
// Ensure we're on the client side
|
|
useEffect(() => {
|
|
setIsClient(true);
|
|
// Detect mobile on mount
|
|
setIsMobile(window.innerWidth < 768);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Only initialize on client side
|
|
if (!isClient || !terminalRef.current || xtermRef.current) return;
|
|
|
|
// Store ref value to avoid stale closure
|
|
const terminalElement = terminalRef.current;
|
|
|
|
// Use setTimeout to ensure DOM is fully ready
|
|
const initTerminal = async () => {
|
|
if (!terminalElement || xtermRef.current) return;
|
|
|
|
// Dynamically import xterm modules to avoid SSR issues
|
|
const { Terminal: XTerm } = await import('@xterm/xterm');
|
|
const { FitAddon } = await import('@xterm/addon-fit');
|
|
const { WebLinksAddon } = await import('@xterm/addon-web-links');
|
|
|
|
// Use the mobile state
|
|
|
|
const terminal = new XTerm({
|
|
theme: {
|
|
background: '#0d1117',
|
|
foreground: '#e6edf3',
|
|
cursor: '#58a6ff',
|
|
cursorAccent: '#0d1117',
|
|
// Let ANSI colors work naturally - only define basic colors
|
|
black: '#484f58',
|
|
red: '#f85149',
|
|
green: '#3fb950',
|
|
yellow: '#d29922',
|
|
blue: '#58a6ff',
|
|
magenta: '#bc8cff',
|
|
cyan: '#39d353',
|
|
white: '#b1bac4',
|
|
brightBlack: '#6e7681',
|
|
brightRed: '#ff7b72',
|
|
brightGreen: '#56d364',
|
|
brightYellow: '#e3b341',
|
|
brightBlue: '#79c0ff',
|
|
brightMagenta: '#d2a8ff',
|
|
brightCyan: '#56d364',
|
|
brightWhite: '#f0f6fc',
|
|
},
|
|
fontSize: isMobile ? 7 : 14,
|
|
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
|
cursorBlink: true,
|
|
cursorStyle: 'block',
|
|
scrollback: 1000,
|
|
tabStopWidth: 4,
|
|
allowTransparency: false,
|
|
convertEol: true,
|
|
disableStdin: false,
|
|
macOptionIsMeta: false,
|
|
rightClickSelectsWord: false,
|
|
wordSeparator: ' ()[]{}\'"`<>|',
|
|
// Better ANSI handling
|
|
allowProposedApi: true,
|
|
// Force proper terminal behavior for interactive applications
|
|
// Use smaller dimensions on mobile but ensure proper fit
|
|
cols: isMobile ? 45 : 80,
|
|
rows: isMobile ? 18 : 24,
|
|
});
|
|
|
|
// Add addons
|
|
const fitAddon = new FitAddon();
|
|
const webLinksAddon = new WebLinksAddon();
|
|
terminal.loadAddon(fitAddon);
|
|
terminal.loadAddon(webLinksAddon);
|
|
|
|
// Enable better ANSI handling
|
|
terminal.options.allowProposedApi = true;
|
|
|
|
// Open terminal
|
|
terminal.open(terminalElement);
|
|
|
|
// Ensure proper terminal rendering
|
|
setTimeout(() => {
|
|
terminal.refresh(0, terminal.rows - 1);
|
|
// Ensure cursor is properly positioned
|
|
terminal.focus();
|
|
|
|
// Force focus on the terminal element
|
|
terminalElement.focus();
|
|
terminalElement.click();
|
|
|
|
// Add click handler to ensure terminal stays focused
|
|
const focusHandler = () => {
|
|
terminal.focus();
|
|
terminalElement.focus();
|
|
};
|
|
terminalElement.addEventListener('click', focusHandler);
|
|
|
|
// Store the handler for cleanup
|
|
(terminalElement as any).focusHandler = focusHandler;
|
|
}, 100);
|
|
|
|
// Fit after a small delay to ensure proper sizing
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
// Force fit multiple times for mobile to ensure proper sizing
|
|
if (isMobile) {
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
}, 200);
|
|
}, 300);
|
|
}
|
|
}, 100);
|
|
|
|
// Add resize listener for mobile responsiveness
|
|
const handleResize = () => {
|
|
if (fitAddonRef.current) {
|
|
setTimeout(() => {
|
|
fitAddonRef.current.fit();
|
|
}, 50);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
// Store the handler for cleanup
|
|
(terminalElement as any).resizeHandler = handleResize;
|
|
|
|
// Store references
|
|
xtermRef.current = terminal;
|
|
fitAddonRef.current = fitAddon;
|
|
|
|
// Mark terminal as ready
|
|
setIsTerminalReady(true);
|
|
|
|
|
|
return () => {
|
|
terminal.dispose();
|
|
};
|
|
};
|
|
|
|
// Initialize with a small delay
|
|
const timeoutId = setTimeout(() => {
|
|
void initTerminal();
|
|
}, 50);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
if (terminalElement && (terminalElement as any).resizeHandler) {
|
|
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
|
}
|
|
if (terminalElement && (terminalElement as any).focusHandler) {
|
|
terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any);
|
|
}
|
|
if (xtermRef.current) {
|
|
xtermRef.current.dispose();
|
|
xtermRef.current = null;
|
|
fitAddonRef.current = null;
|
|
setIsTerminalReady(false);
|
|
}
|
|
};
|
|
}, [isClient, isMobile]);
|
|
|
|
// Handle terminal input with current executionId
|
|
useEffect(() => {
|
|
if (!isTerminalReady || !xtermRef.current) {
|
|
return;
|
|
}
|
|
|
|
const terminal = xtermRef.current;
|
|
|
|
const handleData = (data: string) => {
|
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
const message = {
|
|
action: 'input',
|
|
executionId,
|
|
input: data
|
|
};
|
|
wsRef.current.send(JSON.stringify(message));
|
|
}
|
|
};
|
|
|
|
// Store the handler reference
|
|
inputHandlerRef.current = handleData;
|
|
terminal.onData(handleData);
|
|
|
|
return () => {
|
|
// Clear the handler reference
|
|
inputHandlerRef.current = null;
|
|
};
|
|
}, [executionId, isTerminalReady]); // Depend on terminal ready state
|
|
|
|
useEffect(() => {
|
|
// Prevent multiple connections in React Strict Mode
|
|
if (hasConnectedRef.current || isConnectingRef.current || (wsRef.current && wsRef.current.readyState === WebSocket.OPEN)) {
|
|
return;
|
|
}
|
|
|
|
// Close any existing connection first
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
wsRef.current = null;
|
|
}
|
|
|
|
isConnectingRef.current = true;
|
|
const isInitialConnection = !hasConnectedRef.current;
|
|
hasConnectedRef.current = true;
|
|
|
|
// Small delay to prevent rapid reconnection
|
|
const connectWithDelay = () => {
|
|
// Connect to WebSocket
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}/ws/script-execution`;
|
|
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
setIsConnected(true);
|
|
isConnectingRef.current = false;
|
|
|
|
// Only auto-start on initial connection, not on reconnections
|
|
if (isInitialConnection && !isRunning) {
|
|
// Generate a new execution ID for the initial run
|
|
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
setExecutionId(newExecutionId);
|
|
|
|
const message = {
|
|
action: 'start',
|
|
scriptPath,
|
|
executionId: newExecutionId,
|
|
mode,
|
|
server,
|
|
isUpdate,
|
|
isShell,
|
|
containerId
|
|
};
|
|
ws.send(JSON.stringify(message));
|
|
}
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data as string) as TerminalMessage;
|
|
handleMessage(message);
|
|
} catch (error) {
|
|
console.error('Error parsing WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
ws.onclose = (_event) => {
|
|
setIsConnected(false);
|
|
setIsRunning(false);
|
|
isConnectingRef.current = false;
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
console.error('WebSocket readyState:', ws.readyState);
|
|
setIsConnected(false);
|
|
isConnectingRef.current = false;
|
|
};
|
|
};
|
|
|
|
// Add small delay to prevent rapid reconnection
|
|
const timeoutId = setTimeout(connectWithDelay, 100);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
isConnectingRef.current = false;
|
|
hasConnectedRef.current = false;
|
|
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
|
|
wsRef.current.close();
|
|
}
|
|
};
|
|
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const startScript = () => {
|
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
|
// Generate a new execution ID for each script run
|
|
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
setExecutionId(newExecutionId);
|
|
|
|
setIsStopped(false);
|
|
wsRef.current.send(JSON.stringify({
|
|
action: 'start',
|
|
scriptPath,
|
|
executionId: newExecutionId,
|
|
mode,
|
|
server,
|
|
isUpdate,
|
|
isShell,
|
|
containerId
|
|
}));
|
|
}
|
|
};
|
|
|
|
const stopScript = () => {
|
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
setIsStopped(true);
|
|
setIsRunning(false);
|
|
wsRef.current.send(JSON.stringify({
|
|
action: 'stop',
|
|
executionId
|
|
}));
|
|
}
|
|
};
|
|
|
|
const clearOutput = () => {
|
|
if (xtermRef.current) {
|
|
xtermRef.current.clear();
|
|
}
|
|
};
|
|
|
|
const sendInput = (input: string) => {
|
|
setLastInputSent(input);
|
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
const message = {
|
|
action: 'input',
|
|
executionId,
|
|
input: input
|
|
};
|
|
wsRef.current.send(JSON.stringify(message));
|
|
// Clear the feedback after 2 seconds
|
|
setTimeout(() => setLastInputSent(null), 2000);
|
|
}
|
|
};
|
|
|
|
const handleMobileInput = (input: string) => {
|
|
sendInput(input);
|
|
setMobileInput('');
|
|
};
|
|
|
|
|
|
const handleEnterKey = () => {
|
|
sendInput('\r');
|
|
};
|
|
|
|
// Don't render on server side
|
|
if (!isClient) {
|
|
return (
|
|
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
|
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="flex space-x-1">
|
|
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
|
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
|
</div>
|
|
<span className="text-foreground font-mono text-sm ml-2">
|
|
{scriptName}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="h-96 w-full flex items-center justify-center">
|
|
<div className="text-muted-foreground">Loading terminal...</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
|
{/* Terminal Header */}
|
|
<div className="bg-muted px-2 sm:px-4 py-2 flex items-center justify-between border-b border-border">
|
|
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
|
<div className="flex space-x-1 flex-shrink-0">
|
|
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-red-500 rounded-full"></div>
|
|
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-yellow-500 rounded-full"></div>
|
|
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full"></div>
|
|
</div>
|
|
<span className="text-foreground font-mono text-xs sm:text-sm ml-1 sm:ml-2 truncate">
|
|
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
|
|
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
|
<span className="text-muted-foreground text-xs hidden sm:inline">
|
|
{isConnected ? 'Connected' : 'Disconnected'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Terminal Output */}
|
|
<div
|
|
ref={terminalRef}
|
|
className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
|
|
style={{
|
|
minHeight: '256px'
|
|
}}
|
|
/>
|
|
|
|
{/* Mobile Input Controls - Only show on mobile */}
|
|
<div className="block sm:hidden bg-muted/50 px-2 py-3 border-t border-border">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-foreground">Mobile Input</span>
|
|
{lastInputSent && (
|
|
<span className="text-xs text-green-500 bg-green-500/10 px-2 py-1 rounded">
|
|
Sent: {lastInputSent === '\r' ? 'Enter' :
|
|
lastInputSent === ' ' ? 'Space' :
|
|
lastInputSent === '\b' ? 'Backspace' :
|
|
lastInputSent === '\x1b[A' ? 'Up' :
|
|
lastInputSent === '\x1b[B' ? 'Down' :
|
|
lastInputSent === '\x1b[C' ? 'Right' :
|
|
lastInputSent === '\x1b[D' ? 'Left' :
|
|
lastInputSent}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Button
|
|
onClick={() => setShowMobileInput(!showMobileInput)}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs"
|
|
>
|
|
<Keyboard className="h-4 w-4 mr-1" />
|
|
{showMobileInput ? 'Hide' : 'Show'} Input
|
|
</Button>
|
|
</div>
|
|
|
|
{showMobileInput && (
|
|
<div className="space-y-3">
|
|
{/* Navigation Buttons */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
onClick={() => sendInput('\x1b[A')}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-sm flex items-center justify-center gap-2"
|
|
disabled={!isConnected}
|
|
>
|
|
<ChevronUp className="h-4 w-4" />
|
|
Up
|
|
</Button>
|
|
<Button
|
|
onClick={() => sendInput('\x1b[B')}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-sm flex items-center justify-center gap-2"
|
|
disabled={!isConnected}
|
|
>
|
|
<ChevronDown className="h-4 w-4" />
|
|
Down
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Left/Right Navigation Buttons */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
onClick={() => sendInput('\x1b[D')}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-sm flex items-center justify-center gap-2"
|
|
disabled={!isConnected}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Left
|
|
</Button>
|
|
<Button
|
|
onClick={() => sendInput('\x1b[C')}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-sm flex items-center justify-center gap-2"
|
|
disabled={!isConnected}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
Right
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Button
|
|
onClick={handleEnterKey}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-sm"
|
|
disabled={!isConnected}
|
|
>
|
|
Enter
|
|
</Button>
|
|
<Button
|
|
onClick={() => sendInput(' ')}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-sm"
|
|
disabled={!isConnected}
|
|
>
|
|
Space
|
|
</Button>
|
|
<Button
|
|
onClick={() => sendInput('\b')}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-sm"
|
|
disabled={!isConnected}
|
|
>
|
|
⌫ Backspace
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Custom Input */}
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={mobileInput}
|
|
onChange={(e) => setMobileInput(e.target.value)}
|
|
placeholder="Type command..."
|
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
onKeyPress={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleMobileInput(mobileInput);
|
|
}
|
|
}}
|
|
disabled={!isConnected}
|
|
/>
|
|
<Button
|
|
onClick={() => handleMobileInput(mobileInput)}
|
|
variant="default"
|
|
size="sm"
|
|
disabled={!isConnected || !mobileInput.trim()}
|
|
className="px-3"
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Terminal Controls */}
|
|
<div className="bg-muted px-2 sm:px-4 py-2 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 border-t border-border">
|
|
<div className="flex flex-wrap gap-1 sm:gap-2">
|
|
<Button
|
|
onClick={startScript}
|
|
disabled={!isConnected || (isRunning && !isStopped)}
|
|
variant="default"
|
|
size="sm"
|
|
className={`text-xs sm:text-sm ${isConnected && (!isRunning || isStopped) ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
|
>
|
|
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
|
<span className="hidden sm:inline">Start</span>
|
|
<span className="sm:hidden">▶</span>
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={stopScript}
|
|
disabled={!isRunning}
|
|
variant="default"
|
|
size="sm"
|
|
className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
|
>
|
|
<Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
|
<span className="hidden sm:inline">Stop</span>
|
|
<span className="sm:hidden">⏹</span>
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={clearOutput}
|
|
variant="secondary"
|
|
size="sm"
|
|
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
|
>
|
|
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
|
<span className="hidden sm:inline">Clear</span>
|
|
<span className="sm:hidden">🗑</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={onClose}
|
|
variant="secondary"
|
|
size="sm"
|
|
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
|
|
>
|
|
<X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |