mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: add HostConsole component for interactive terminal access and integrate into EditorLayout
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.gitignore
|
||||
plans/
|
||||
Product Requirements Document
|
||||
*.md
|
||||
.DS_Store
|
||||
+5
-2
@@ -20,6 +20,9 @@ FROM node:20-alpine AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Install build dependencies for node-pty native modules
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy backend package files
|
||||
COPY backend/package*.json ./
|
||||
|
||||
@@ -35,8 +38,8 @@ RUN npm run build
|
||||
# Stage 3: Production
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install Docker CLI and Docker Compose CLI
|
||||
RUN apk add --no-cache docker-cli docker-cli-compose
|
||||
# Install Docker CLI, Docker Compose CLI, and Bash for Host Console
|
||||
RUN apk add --no-cache docker-cli docker-cli-compose bash
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
Generated
+17
@@ -20,6 +20,7 @@
|
||||
"dockerode": "^4.0.9",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-pty": "^1.1.0",
|
||||
"systeminformation": "^5.31.1",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2"
|
||||
@@ -1813,6 +1814,22 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty/node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.13.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"dockerode": "^4.0.9",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-pty": "^1.1.0",
|
||||
"systeminformation": "^5.31.1",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2"
|
||||
|
||||
@@ -13,6 +13,8 @@ import si from 'systeminformation';
|
||||
import http from 'http';
|
||||
import { spawn, exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { HostTerminalService } from './services/HostTerminalService';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -220,6 +222,7 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
// Check if this is a stack logs WebSocket request
|
||||
const url = req.url || '';
|
||||
const logsMatch = url.match(/^\/api\/stacks\/([^/]+)\/logs$/);
|
||||
const hostConsoleMatch = url.match(/^\/api\/system\/host-console/);
|
||||
|
||||
if (logsMatch) {
|
||||
// Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs
|
||||
@@ -235,6 +238,29 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (hostConsoleMatch) {
|
||||
const hostConsoleWss = new WebSocket.Server({ noServer: true });
|
||||
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
let targetDirectory = fileSystemService.getBaseDir();
|
||||
try {
|
||||
const reqUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
|
||||
const stackParam = reqUrl.searchParams.get('stack');
|
||||
if (stackParam) {
|
||||
targetDirectory = path.join(targetDirectory, stackParam);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parsing error, fallback to base dir
|
||||
}
|
||||
try {
|
||||
HostTerminalService.spawnTerminal(ws, targetDirectory);
|
||||
} catch (error) {
|
||||
console.error('Failed to spawn host terminal:', error);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`Error spawning terminal: ${(error as Error).message}\r\n`);
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Generic terminal WebSocket
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as os from 'os';
|
||||
import * as pty from 'node-pty';
|
||||
import { WebSocket } from 'ws';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
function getUnixShell() {
|
||||
try {
|
||||
execSync('which bash', { stdio: 'ignore' });
|
||||
return 'bash';
|
||||
} catch {
|
||||
return 'sh';
|
||||
}
|
||||
}
|
||||
|
||||
export class HostTerminalService {
|
||||
static spawnTerminal(ws: WebSocket, targetDirectory: string) {
|
||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : getUnixShell();
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
cwd: targetDirectory,
|
||||
env: process.env as Record<string, string>,
|
||||
});
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data); // Raw-Down protocol
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (message: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(message); // JSON-Up protocol
|
||||
if (parsed.type === 'input') {
|
||||
ptyProcess.write(parsed.payload);
|
||||
} else if (parsed.type === 'resize') {
|
||||
ptyProcess.resize(parsed.cols, parsed.rows);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse Host terminal message:', e);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Host terminal WebSocket closed, cleaning up PTY process');
|
||||
ptyProcess.kill();
|
||||
});
|
||||
|
||||
// Handle PTY process exit
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
console.log(`Host terminal PTY process exited with code ${exitCode} and signal ${signal}`);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user