feat: add HostConsole component for interactive terminal access and integrate into EditorLayout

This commit is contained in:
SaelixCode
2026-02-26 19:52:43 -05:00
parent 4932d90e4a
commit fcf0c9f983
8 changed files with 309 additions and 15 deletions
+16 -13
View File
@@ -4,6 +4,7 @@ import TerminalComponent from './Terminal';
import ErrorBoundary from './ErrorBoundary';
import HomeDashboard from './HomeDashboard';
import BashExecModal from './BashExecModal';
import HostConsole from './HostConsole';
import MaintenanceModal from './MaintenanceModal';
import { Button } from './ui/button';
import { Input } from './ui/input';
@@ -69,7 +70,7 @@ export default function EditorLayout() {
}
return true; // Default to dark mode
});
const [showConsole, setShowConsole] = useState(true);
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console'>('dashboard');
const [isEditing, setIsEditing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
@@ -200,6 +201,7 @@ export default function EditorLayout() {
const res = await apiFetch(`/stacks/${filename}`);
const text = await res.text();
setSelectedFile(filename);
setActiveView('editor');
setContent(text || '');
setOriginalContent(text || '');
@@ -610,6 +612,7 @@ export default function EditorLayout() {
setEnvExists(false);
setContainers([]);
setIsEditing(false);
setActiveView('dashboard');
}}
title="Go to Home Dashboard"
>
@@ -618,10 +621,10 @@ export default function EditorLayout() {
</Button>
{/* Console Toggle */}
<Button
variant="outline"
variant={activeView === 'host-console' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setShowConsole(!showConsole)}
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
>
<Terminal className="w-4 h-4 mr-2" />
Console
@@ -660,7 +663,9 @@ export default function EditorLayout() {
{/* Main Workspace */}
<div className="flex-1 overflow-y-auto p-6">
{!isLoading && selectedFile ? (
{activeView === 'host-console' ? (
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
) : !isLoading && selectedFile && activeView === 'editor' ? (
<ErrorBoundary>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-stretch">
{/* Left Column (Command Center & Terminal) */}
@@ -815,16 +820,14 @@ export default function EditorLayout() {
</Card>
{/* Terminal Section */}
{showConsole && (
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 min-h-[300px]">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Terminal</h3>
<div className="h-[calc(100%-24px)]">
<ErrorBoundary>
<TerminalComponent stackName={stackName} />
</ErrorBoundary>
</div>
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 min-h-[300px]">
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Terminal</h3>
<div className="h-[calc(100%-24px)]">
<ErrorBoundary>
<TerminalComponent stackName={stackName} />
</ErrorBoundary>
</div>
)}
</div>
</div>
{/* Right Column (The Editor) */}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { Terminal as TerminalIcon, X } from 'lucide-react';
import { Button } from './ui/button';
import '@xterm/xterm/css/xterm.css';
interface HostConsoleProps {
stackName?: string | null;
onClose: () => void;
}
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const cleanup = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
setIsConnected(false);
}, []);
useEffect(() => {
const container = terminalRef.current;
if (!container) return;
let mounted = true;
const term = new Terminal({
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#000000',
selectionBackground: 'rgba(255, 255, 255, 0.3)',
},
fontFamily: 'Consolas, "Courier New", monospace',
fontSize: 14,
cursorBlink: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(container);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
requestAnimationFrame(() => {
try {
if (mounted) fitAddon.fit();
} catch {
// Ignore fit errors during initial render
}
});
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${stackName ? `?stack=${encodeURIComponent(stackName)}` : ''}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (!mounted) return;
setIsConnected(true);
term.focus();
setTimeout(() => {
try {
if (mounted) fitAddon.fit();
} catch {
// Ignore
}
if (ws.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) {
ws.send(JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}));
}
}, 100);
};
ws.onmessage = (event) => {
if (!mounted) return;
const text = typeof event.data === 'string' ? event.data : event.data.toString();
term.write(text);
};
ws.onerror = () => {
if (!mounted) return;
term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n');
setIsConnected(false);
};
ws.onclose = () => {
if (!mounted) return;
term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n');
setIsConnected(false);
};
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'input',
payload: data,
}));
}
});
let resizeTimeout: ReturnType<typeof setTimeout>;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (!mounted || !fitAddonRef.current || !wsRef.current) return;
try {
fitAddonRef.current.fit();
} catch {
return;
}
if (wsRef.current.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) {
wsRef.current.send(JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}));
}
}, 50);
});
resizeObserver.observe(container);
return () => {
mounted = false;
resizeObserver.disconnect();
clearTimeout(resizeTimeout);
cleanup();
};
}, [stackName, cleanup]);
return (
<div className="flex flex-col h-full w-full bg-background border rounded-lg overflow-hidden shadow-sm">
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0">
<div className="flex items-center gap-2 font-medium">
<TerminalIcon className="w-4 h-4 text-muted-foreground" />
<span>Host Console</span>
{stackName && (
<span className="text-muted-foreground font-normal text-sm">
({stackName})
</span>
)}
{isConnected && (
<span className="ml-2 text-xs bg-green-500/10 text-green-500 px-2 py-0.5 rounded-full border border-green-500/20">
Connected
</span>
)}
</div>
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 gap-1.5 text-muted-foreground hover:text-foreground">
<X className="w-4 h-4" />
Close Console
</Button>
</div>
<div className="flex-1 bg-[#1e1e1e] p-2 min-h-0 relative" style={{ overflow: 'hidden' }}>
<div ref={terminalRef} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}